后端迁移到fastapi #3

Merged
Mplan merged 7 commits from switch-to-self-host into main 2026-07-18 20:49:09 +08:00
22 changed files with 701 additions and 1342 deletions
Showing only changes of commit 613452b7fe - Show all commits
+3
View File
@@ -0,0 +1,3 @@
VITE_API_URL=http://localhost:8000/api/v1
VITE_AMAP_KEY=
VITE_SITE_URL=http://localhost:5173
+43 -35
View File
@@ -9,33 +9,33 @@
## Architecture
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + Supabase + AMap (高德地图) + Naive UI**
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + FastAPI + AMap (高德地图) + Naive UI**
- 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
- FastAPI client at `src/lib/api.ts`; API base URL comes from `VITE_API_URL`
- AMap loaded lazily via `src/lib/amap.ts` with type declarations in `src/types/amap.d.ts`
- UI language is Chinese (zh-CN)
- All DB operations are direct `supabase.from(...)` calls from the browser — security depends on Supabase RLS policies
- All persistence, storage, authorization, and admin operations go through the FastAPI backend
## Directory Map
| Path | Purpose |
| --- | --- |
| `src/lib/` | Singletons and utilities: supabase, amap, canvas patch, cloudTypes constants, cloudBadges canvas renderer, SEO meta builder |
| `src/lib/` | Singletons and utilities: FastAPI client, amap, canvas patch, cloudTypes constants, cloudBadges canvas renderer, SEO meta builder |
| `src/stores/` | Pinia stores: auth, clouds (cloud_types cache), encyclopedia (collection + unlock tracking), profile (user pages + cloud CRUD) |
| `src/composables/` | Vue composables: `useUpload` (batch upload with thumbnail generation, EXIF extraction, badge unlocking) |
| `src/composables/` | Vue composables: `useUpload` (batch multipart upload, EXIF extraction, badge result handling) |
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
| `src/components/profile/` | ContributionHeatmap |
| `src/style.css` | Global visual system hooks: shared button/card utility classes, base typography, body background |
| `src/views/` | Route-level page components (see Routes below) |
| `src/types/` | TypeScript types: database models (`database.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
## Routes
| Path | View | Auth Required |
| --- | --- | :---: |
| `/` | MapView | No |
| `/login`, `/register`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
| `/upload` | UploadView | Yes |
| `/encyclopedia` | EncyclopediaView | No |
| `/encyclopedia/:id` | CloudTypeView | No |
@@ -46,39 +46,52 @@
| `/profile/:id` | ProfileView (public) | No |
| `/admin` | AdminView | Yes (admin) |
| `/403` | ForbiddenView | No |
| `/401` | AuthRequiredView | No |
| `/:pathMatch(.*)*` | NotFoundView | No |
- Route guards in `router/index.ts`: `requiresAuth` redirects to `/login`, `requiresAdmin` redirects to `/403`
- Route guards in `router/index.ts`: `requiresAuth` redirects to `/401` with the intended URL in the `redirect` query; `requiresAdmin` redirects to `/403`
- SEO meta tags applied per-route via `lib/seo.ts` in `router.afterEach`
## Auth Flow
- **`main.ts` initializes auth before mounting**: `authStore.initialize()` is called, which calls `getSession()` and subscribes to `onAuthStateChange`. The app only mounts after the initial session check completes.
- **Login sets `user.value` explicitly**: `login()` extracts `data.user` from `signInWithPassword` 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.
- Auth error messages are translated to Chinese in the store.
- `register()` checks username uniqueness before calling `signUp()` — username is passed via `options.data.username`.
- **`main.ts` initializes auth before mounting**: `authStore.initialize()` exchanges the HttpOnly refresh cookie for an access token. The app only mounts after this initial check.
- Access tokens live in memory only. Refresh cookies are managed by the backend and sent with `credentials: 'include'`.
- `src/lib/api.ts` performs one automatic refresh-and-retry after an authenticated request receives `401`.
- API auth state changes are synchronized back to the Pinia store through `opencloud:auth-updated` and `opencloud:auth-expired` window events.
- Email confirmation and password reset pages consume the opaque `token` query parameter generated by the backend.
- Passwords must contain at least 8 characters in registration, settings, and reset flows.
- Registration, email confirmation, login, password reset, profile creation, and uniqueness checks are backend responsibilities.
## Stores
- **auth** — User session, profile, login/register/logout, username/password update, password reset. `initialize()` must be called before app mounts.
- **clouds** — Simple cache of `cloud_types` table. Fetched once, shared across views.
- **clouds** — Simple cache of cloud types returned by `GET /cloud-types`. Fetched once and shared across views.
- **encyclopedia** — Cloud types + user's collection (unlock state). Tracks `unlockPercent` for progress display. Depends on `authStore`.
- **profile** — User profile pages. Fetches profile + cloud list per-user. Supports update/delete/visibility-toggle with optimistic cache patching. `deleteClouds` also removes from Supabase Storage.
- **profile** — User profile pages. Fetches profile + cloud list per-user. Supports update/delete/visibility-toggle with optimistic cache patching; backend deletion also removes stored files.
## Supabase
## FastAPI Backend
- 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.
- `user_collections` tracks encyclopedia unlocks with `first_cloud_id` FK to `clouds`.
- Backend project lives in the sibling directory `../opencloud-backend`.
- Default development API URL is `http://localhost:8000/api/v1`; set `VITE_API_URL` for deployed environments.
- Frontend route guards are for navigation UX only. The backend enforces ownership, authentication, admin roles, and disabled-account rules.
- Images are uploaded as multipart form data. The backend stores originals, creates thumbnails, blurs coordinates, writes database records, and atomically unlocks badges.
## API Conventions
- All requests must go through `src/lib/api.ts`; do not call `fetch` directly from views, stores, or composables.
- API paths passed to `apiRequest()` are relative to `VITE_API_URL`, for example `/clouds` rather than `/api/v1/clouds`.
- Public requests explicitly use `{ auth: false }`. Authenticated requests use the default and receive a Bearer access token.
- JSON request bodies are plain objects. Image uploads use `FormData`; do not set the multipart `Content-Type` header manually.
- FastAPI errors use `detail`; `ApiError` converts string and validation-array details into a user-facing message.
- Backend DTO fields use `snake_case`. Page/store adapters may expose display-only camelCase fields such as `cloudTypeName`.
- Paginated endpoints return `items`, `page`, `page_size`, `total`, and `total_pages`.
- Cloud batch mutation endpoints accept at most 100 IDs. Profile and admin code split larger selections into chunks of 100.
## Gallery Pagination
- Page-based navigation (50 items/page), not infinite scroll.
- `resolveSearchFilters()` pre-fetches user IDs or cloud type IDs before the main query.
- `buildFilteredQuery()` returns a chainable query builder; `loadPage()` forks it into a `count` query and a `data` query that run in parallel via `Promise.all`.
- The backend resolves cloud-type/custom-type searches and `@username` searches.
- Each response includes `items`, `total`, `page`, `page_size`, and `total_pages`.
- Search debounced at 250ms.
## Map Timeline
@@ -89,7 +102,8 @@
## Upload Flow
- `useUpload` composable handles: file selection (drag/drop/click), client-side thumbnail generation (JPEG, max 640px edge, 0.72 quality), EXIF date extraction, coordinate blurring (2 decimal places), Supabase Storage upload (original + thumbnail), DB insert with `status: 'pending'`, badge unlock detection via `user_collections` upsert.
- `useUpload` handles file selection, previews, EXIF date extraction, validation, sequential multipart uploads, progress, and badge results. Thumbnail generation, coordinate blurring, storage, database insertion, and badge unlocking run on the backend.
- Batch items upload sequentially. If a later item fails, earlier successful uploads remain saved; there is no transactional rollback or resumable upload in the frontend.
- `UploadView` is the full-page batch uploader. `QuickUploadModal` is a single-image shortcut from the map page.
## Build & Deploy
@@ -100,22 +114,16 @@
## Environment Variables
Required (app won't start without):
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_PUBLISHABLE_KEY`
Required for map features:
- `VITE_AMAP_KEY`
Optional:
- `VITE_SITE_URL` — canonical URL for SEO meta and sitemap (falls back to Vercel env or hardcoded default)
- `VITE_API_URL` — FastAPI base URL including `/api/v1` (defaults to `http://localhost:8000/api/v1`)
- `VITE_SITE_URL` — canonical frontend URL used by route SEO and generated sitemap; set it explicitly in deployed environments
Future (not in `.env`):
- `OPENAI_API_KEY`, `OPENWEATHERMAP_API_KEY`
Backend-only variables such as database credentials, JWT secrets, SMTP settings, cookie policy, CORS origins, and upload paths belong in `../opencloud-backend/.env`, never in the Vite environment.
## Naive UI
@@ -144,9 +152,9 @@ Future (not in `.env`):
- Header action buttons use slight hover lift (`translate(-1px, -1px)`) with hard-offset shadow growth; new header-like actions should follow that interaction pattern.
- Heatmap view toggles in `ContributionHeatmap` intentionally use separate buttons with gap spacing instead of `NButtonGroup`, because grouped buttons visually collide once hard shadows and hover transforms are applied.
## MVP Constraints (from plan.md)
## MVP Constraints
- No Supabase Realtime — refresh-based loading
- No realtime updates — 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
+21 -3
View File
@@ -1,5 +1,23 @@
# Vue 3 + TypeScript + Vite
# OpenCloud 前端
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Vue 3 + TypeScript + Vite 前端,通过 FastAPI 提供认证、云图、图鉴、个人主页和管理后台数据。
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
## 本地开发
1. 在相邻的 `opencloud-backend` 项目中启动 FastAPI 服务。
2. 复制 `.env.example` 为 `.env`,配置高德地图 Key;如果后端不在默认地址,修改 `VITE_API_URL`。
3. 安装依赖并启动前端:
```bash
npm install
npm run dev
```
默认后端地址为 `http://localhost:8000/api/v1`。
## 检查与构建
```bash
npx vue-tsc -b
npm run build
```
+3 -95
View File
@@ -9,7 +9,6 @@
"version": "0.0.0",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@supabase/supabase-js": "^2.106.1",
"@vercel/speed-insights": "^2.0.0",
"naive-ui": "^2.44.1",
"pinia": "^3.0.4",
@@ -506,90 +505,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.106.1",
"resolved": "https://registry.npmmirror.com/@supabase/auth-js/-/auth-js-2.106.1.tgz",
"integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.106.1",
"resolved": "https://registry.npmmirror.com/@supabase/functions-js/-/functions-js-2.106.1.tgz",
"integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.106.1",
"resolved": "https://registry.npmmirror.com/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz",
"integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.106.1",
"resolved": "https://registry.npmmirror.com/@supabase/realtime-js/-/realtime-js-2.106.1.tgz",
"integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.106.1",
"resolved": "https://registry.npmmirror.com/@supabase/storage-js/-/storage-js-2.106.1.tgz",
"integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.106.1",
"resolved": "https://registry.npmmirror.com/@supabase/supabase-js/-/supabase-js-2.106.1.tgz",
"integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.106.1",
"@supabase/functions-js": "2.106.1",
"@supabase/postgrest-js": "2.106.1",
"@supabase/realtime-js": "2.106.1",
"@supabase/storage-js": "2.106.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.0.tgz",
@@ -1442,15 +1357,6 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmmirror.com/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
@@ -2035,7 +1941,9 @@
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/typescript": {
"version": "6.0.3",
-1
View File
@@ -10,7 +10,6 @@
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@supabase/supabase-js": "^2.106.1",
"@vercel/speed-insights": "^2.0.0",
"naive-ui": "^2.44.1",
"pinia": "^3.0.4",
+80 -285
View File
@@ -1,6 +1,8 @@
import { ref } from 'vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useProfileStore } from '@/stores/profile'
import type { CloudCreateResponse } from '@/types/api'
import type { CloudType } from '@/types/database'
export interface UploadItem {
@@ -32,8 +34,6 @@ export interface UploadResult {
}
let nextId = 0
const THUMBNAIL_MAX_EDGE = 640
const THUMBNAIL_QUALITY = 0.72
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
@@ -44,84 +44,6 @@ function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
})
}
function loadImageElement(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file)
const image = new Image()
image.onload = () => {
URL.revokeObjectURL(objectUrl)
resolve(image)
}
image.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error('图片读取失败'))
}
image.src = objectUrl
})
}
function getResizedDimensions(width: number, height: number, maxEdge: number) {
const largestEdge = Math.max(width, height)
if (largestEdge <= maxEdge) {
return { width, height }
}
const scale = maxEdge / largestEdge
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
}
}
function fileBaseName(name: string) {
const idx = name.lastIndexOf('.')
return idx === -1 ? name : name.slice(0, idx)
}
async function renderJpegFile(
image: HTMLImageElement,
sourceFile: File,
maxEdge: number,
quality: number,
suffix = '',
) {
const { width, height } = getResizedDimensions(image.naturalWidth || image.width, image.naturalHeight || image.height, maxEdge)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) {
throw new Error('图片压缩失败')
}
context.drawImage(image, 0, 0, width, height)
const blob = await new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve, 'image/jpeg', quality)
})
if (!blob) {
throw new Error('图片压缩失败')
}
return new File([blob], `${fileBaseName(sourceFile.name)}${suffix}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
})
}
async function createUploadAssets(file: File) {
const image = await loadImageElement(file)
return {
thumbnailFile: await renderJpegFile(image, file, THUMBNAIL_MAX_EDGE, THUMBNAIL_QUALITY, '-thumb'),
}
}
function extractExifDate(buffer: ArrayBuffer): string | null {
const view = new DataView(buffer)
if (view.getUint16(0, false) !== 0xffd8) return null
@@ -146,23 +68,20 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
const tiffStart = exifStart + 6
const isLE = view.getUint16(tiffStart, false) === 0x4949
const ifd0Offset = view.getUint32(tiffStart + 4, isLE)
const ifdStart = tiffStart + ifd0Offset
const ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE)
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()
}
if (view.getUint16(entryStart, isLE) !== 0x9003) continue
const dataOffset = view.getUint32(entryStart + 8, isLE)
const dateStr = Array.from({ length: 20 }, (_, j) =>
String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)),
).join('')
const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3'))
if (!isNaN(date.getTime())) return date.toISOString()
}
}
offset += length - 2
@@ -170,51 +89,12 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
return null
}
async function fetchUnlockedTypeIds(userId: string) {
const { data, error } = await supabase
.from('user_collections')
.select('cloud_type_id')
.eq('user_id', userId)
if (error) throw error
return new Set(
(data || [])
.map(row => row.cloud_type_id)
.filter((cloudTypeId): cloudTypeId is number => typeof cloudTypeId === 'number'),
)
}
async function fetchBadgeDetails(unlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }>) {
const ids = unlockedRows.map(item => item.cloudTypeId)
const { data, error } = await supabase
.from('cloud_types')
.select('id,name,name_en,rarity')
.in('id', ids)
if (error) throw error
const typeMap = new Map(
((data || []) as Array<Pick<CloudType, 'id' | 'name' | 'name_en' | 'rarity'>>).map(item => [item.id, item]),
)
return unlockedRows
.map(item => {
const cloudType = typeMap.get(item.cloudTypeId)
if (!cloudType) return null
return {
cloudTypeId: cloudType.id,
cloudName: cloudType.name,
cloudNameEn: cloudType.name_en,
rarity: cloudType.rarity,
unlockedAt: item.unlockedAt,
} satisfies UnlockedBadge
})
.filter((item): item is UnlockedBadge => item !== null)
function appendOptional(form: FormData, key: string, value: string | number | null) {
if (value !== null && value !== '') form.append(key, String(value))
}
export function useUpload() {
const authStore = useAuthStore()
const profileStore = useProfileStore()
const items = ref<UploadItem[]>([])
const uploading = ref(false)
@@ -223,15 +103,14 @@ export function useUpload() {
const totalItems = ref(0)
async function addFiles(files: File[]) {
const imageFiles = files.filter(f => f.type.startsWith('image/'))
const imageFiles = files.filter(file => file.type.startsWith('image/'))
for (const file of imageFiles) {
let capturedAt = new Date().toISOString()
try {
const buffer = await readFileAsArrayBuffer(file)
const exifDate = extractExifDate(buffer)
const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
if (exifDate) capturedAt = exifDate
} catch {
// fallback to now
// 无 EXIF 时使用当前时间。
}
items.value.push({
@@ -252,182 +131,86 @@ export function useUpload() {
}
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)
}
const index = items.value.findIndex(item => item.id === id)
if (index === -1) return
URL.revokeObjectURL(items.value[index].preview)
items.value.splice(index, 1)
}
function clearAll() {
for (const item of items.value) {
URL.revokeObjectURL(item.preview)
}
items.value.forEach(item => URL.revokeObjectURL(item.preview))
items.value = []
}
function validateItem(item: UploadItem): boolean {
function validateItem(item: UploadItem) {
const errors: Record<string, string> = {}
if (!item.cloudCategoryId) {
errors.cloudCategory = '请选择类别'
}
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
errors.cloudCategory = '请输入自定义云型名称'
}
if (!item.capturedAt) {
errors.capturedAt = '请选择拍摄时间'
}
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 = '经纬度必须同时填写'
}
if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写'
if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写'
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
function validateAll() {
return items.value.map(validateItem).every(Boolean)
}
async function uploadAll(): Promise<UploadResult> {
if (!validateAll()) {
return { ok: false, unlockedBadges: [] }
}
if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
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 { ok: false, unlockedBadges: [] }
}
const badges = new Map<number, UnlockedBadge>()
try {
let unlockedTypeIds = new Set<number>()
try {
unlockedTypeIds = await fetchUnlockedTypeIds(userId)
} catch {
unlockedTypeIds = new Set<number>()
}
for (let index = 0; index < items.value.length; index++) {
const item = items.value[index]
currentItemIndex.value = index + 1
overallProgress.value = Math.round(index / items.value.length * 100)
const newlyUnlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }> = []
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 basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const ext = item.file.name.split('.').pop() || 'jpg'
const imagePath = `${basePath}.${ext}`
const thumbnailPath = `${basePath}-thumb.jpg`
const { thumbnailFile } = await createUploadAssets(item.file)
const { error: imgError } = await supabase.storage
.from('clouds')
.upload(imagePath, item.file, {
upsert: false,
contentType: item.file.type,
})
if (imgError) throw imgError
const { error: thumbError } = await supabase.storage
.from('clouds')
.upload(thumbnailPath, thumbnailFile, {
upsert: false,
contentType: thumbnailFile.type,
})
if (thumbError) throw thumbError
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
const { data: { publicUrl: thumbnailUrl } } = supabase.storage.from('clouds').getPublicUrl(thumbnailPath)
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
const { data: insertedCloud, 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: thumbnailUrl,
latitude,
longitude,
location_name: item.locationName || null,
description: item.description.trim() || null,
captured_at: item.capturedAt,
status: 'pending',
is_hidden: item.isHidden,
})
.select('id,cloud_type_id')
.single()
if (dbError) throw dbError
if (
insertedCloud &&
typeof insertedCloud.cloud_type_id === 'number' &&
!unlockedTypeIds.has(insertedCloud.cloud_type_id)
) {
try {
const { data: collectionRow, error: collectionError } = await supabase
.from('user_collections')
.insert({
user_id: userId,
cloud_type_id: insertedCloud.cloud_type_id,
first_cloud_id: insertedCloud.id,
})
.select('cloud_type_id,unlocked_at')
.single()
if (collectionError) {
const duplicate = collectionError.code === '23505'
if (!duplicate) throw collectionError
} else if (collectionRow) {
unlockedTypeIds.add(collectionRow.cloud_type_id)
newlyUnlockedRows.push({
cloudTypeId: collectionRow.cloud_type_id,
unlockedAt: collectionRow.unlocked_at,
})
}
} catch {
// Ignore collection sync failures so uploads can still complete.
}
const form = new FormData()
form.append('image', item.file)
if (item.cloudCategoryId === 'other') {
form.append('custom_cloud_type', item.customCloudType.trim())
} else {
form.append('cloud_type_id', String(item.cloudCategoryId))
}
appendOptional(form, 'latitude', item.latitude)
appendOptional(form, 'longitude', item.longitude)
appendOptional(form, 'location_name', item.locationName.trim())
appendOptional(form, 'description', item.description.trim())
appendOptional(form, 'captured_at', item.capturedAt)
form.append('is_hidden', String(item.isHidden))
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
const result = await apiRequest<CloudCreateResponse>('/clouds', {
method: 'POST',
body: form,
})
if (result.unlocked_badge) {
const badge = result.unlocked_badge
badges.set(badge.cloud_type_id, {
cloudTypeId: badge.cloud_type_id,
cloudName: badge.cloud_name,
cloudNameEn: badge.cloud_name_en,
rarity: badge.rarity,
unlockedAt: badge.unlocked_at,
})
}
overallProgress.value = Math.round((index + 1) / items.value.length * 100)
}
for (const item of items.value) {
URL.revokeObjectURL(item.preview)
}
items.value = []
profileStore.invalidateUser(userId)
return {
ok: true,
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
}
clearAll()
profileStore.invalidateUser(authStore.user.id)
return { ok: true, unlockedBadges: [...badges.values()] }
} catch {
return { ok: false, unlockedBadges: [] }
} finally {
@@ -435,5 +218,17 @@ export function useUpload() {
}
}
return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateItem, validateAll, uploadAll }
return {
items,
uploading,
overallProgress,
currentItemIndex,
totalItems,
addFiles,
removeItem,
clearAll,
validateItem,
validateAll,
uploadAll,
}
}
+134
View File
@@ -0,0 +1,134 @@
import type { AuthResponse } from '@/types/api'
const DEFAULT_API_URL = 'http://localhost:8000/api/v1'
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated'
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
let accessToken: string | null = null
let refreshPromise: Promise<AuthResponse> | null = null
export class ApiError extends Error {
status: number
detail: unknown
constructor(message: string, status: number, detail?: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.detail = detail
}
}
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: BodyInit | Record<string, unknown> | null
auth?: boolean
retry?: boolean
}
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
return !!body
&& typeof body === 'object'
&& !(body instanceof FormData)
&& !(body instanceof Blob)
&& !(body instanceof URLSearchParams)
&& !(body instanceof ArrayBuffer)
}
function detailToMessage(detail: unknown, fallback: string) {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const messages = detail
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
.filter(Boolean)
if (messages.length) return messages.join(';')
}
return fallback
}
async function toApiError(response: Response) {
let detail: unknown
try {
const payload = await response.json() as { detail?: unknown }
detail = payload.detail
} catch {
detail = undefined
}
return new ApiError(
detailToMessage(detail, `请求失败(${response.status})`),
response.status,
detail,
)
}
export function setAccessToken(token: string | null) {
accessToken = token
}
export function clearAccessToken() {
accessToken = null
}
async function request<T>(path: string, options: ApiRequestOptions = {}) {
const { body, auth = true, retry = true, ...requestOptions } = options
const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
if (isJsonBody(body)) {
headers.set('Content-Type', 'application/json')
requestBody = JSON.stringify(body)
}
if (auth && accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
const response = await fetch(`${API_URL}${path}`, {
...requestOptions,
body: requestBody,
headers,
credentials: 'include',
})
if (response.status === 401 && auth && accessToken && retry) {
try {
const authResponse = await refreshSession()
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
return request<T>(path, { ...options, retry: false })
} catch {
clearAccessToken()
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
}
}
if (!response.ok) throw await toApiError(response)
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
return request<T>(path, options)
}
export function refreshSession() {
if (!refreshPromise) {
refreshPromise = (async () => {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok) throw await toApiError(response)
const authResponse = await response.json() as AuthResponse
setAccessToken(authResponse.access_token)
return authResponse
})().finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
-152
View File
@@ -1,152 +0,0 @@
import {
createClient,
type EmailOtpType,
type Session,
type SupabaseClient,
} from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
const knownEmailOtpTypes = new Set<EmailOtpType>([
'signup',
'invite',
'magiclink',
'recovery',
'email_change',
'email',
])
type AuthCallbackParams = {
accessToken: string | null
code: string | null
error: string | null
errorCode: string | null
errorDescription: string | null
refreshToken: string | null
tokenHash: string | null
type: EmailOtpType | null
}
type ResolveEmailAuthCallbackOptions = {
allowedTypes: EmailOtpType[]
client: SupabaseClient
}
type ResolveEmailAuthCallbackResult =
| {
ok: true
session: Session | null
type: EmailOtpType | null
}
| {
ok: false
error: string
}
function parseEmailOtpType(value: string | null): EmailOtpType | null {
if (!value || !knownEmailOtpTypes.has(value as EmailOtpType)) {
return null
}
return value as EmailOtpType
}
export function readAuthCallbackParams(): AuthCallbackParams {
const search = new URLSearchParams(window.location.search)
const hash = new URLSearchParams(window.location.hash.slice(1))
return {
code: search.get('code'),
tokenHash: search.get('token_hash') ?? hash.get('token_hash'),
accessToken: hash.get('access_token'),
refreshToken: hash.get('refresh_token'),
type: parseEmailOtpType(search.get('type') ?? hash.get('type')),
error: search.get('error') ?? hash.get('error'),
errorCode: search.get('error_code') ?? hash.get('error_code'),
errorDescription: search.get('error_description') ?? hash.get('error_description'),
}
}
export function clearAuthCallbackUrl() {
window.history.replaceState(null, '', window.location.pathname)
}
export function createDetachedAuthClient() {
return createClient(supabaseUrl, supabaseKey, {
auth: {
autoRefreshToken: false,
detectSessionInUrl: false,
persistSession: false,
},
})
}
export async function resolveEmailAuthCallback(
options: ResolveEmailAuthCallbackOptions,
): Promise<ResolveEmailAuthCallbackResult> {
const { client, allowedTypes } = options
const params = readAuthCallbackParams()
const allowedTypeSet = new Set(allowedTypes)
if (params.error || params.errorCode || params.errorDescription) {
return {
ok: false,
error: params.errorDescription || '链接无效或已过期。',
}
}
try {
let session: Session | null = null
if (params.tokenHash && params.type && allowedTypeSet.has(params.type)) {
const { data, error } = await client.auth.verifyOtp({
token_hash: params.tokenHash,
type: params.type,
})
if (error) throw error
session = data.session
} else if (params.code) {
const { data, error } = await client.auth.exchangeCodeForSession(params.code)
if (error) throw error
session = data.session
} else if (
params.accessToken
&& params.refreshToken
&& params.type
&& allowedTypeSet.has(params.type)
) {
const { data, error } = await client.auth.setSession({
access_token: params.accessToken,
refresh_token: params.refreshToken,
})
if (error) throw error
session = data.session
} else {
return {
ok: false,
error: '链接无效或已过期。',
}
}
if (!session) {
const { data, error } = await client.auth.getSession()
if (error) throw error
session = data.session
}
clearAuthCallbackUrl()
return {
ok: true,
session,
type: params.type,
}
} catch (error) {
const message = error instanceof Error ? error.message : '链接无效或已过期。'
return {
ok: false,
error: message,
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
if (!supabaseUrl || !supabaseKey) {
throw new Error('Missing Supabase environment variables')
}
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
// Email confirmation and password recovery are handled by route components.
detectSessionInUrl: false,
},
})
+68 -116
View File
@@ -1,149 +1,102 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase'
import type { User } from '@supabase/supabase-js'
import {
AUTH_EXPIRED_EVENT,
AUTH_UPDATED_EVENT,
apiRequest,
clearAccessToken,
refreshSession,
setAccessToken,
} from '@/lib/api'
import type { AuthResponse, AuthUser } from '@/types/api'
import type { Profile } from '@/types/database'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const user = ref<AuthUser | null>(null)
const profile = ref<Profile | null>(null)
const loading = ref(true)
let listenersAttached = false
const isLoggedIn = computed(() => !!user.value)
const isAdmin = computed(() => profile.value?.role === 'admin')
async function fetchProfile(userId?: string) {
const id = userId ?? user.value?.id
if (!id) return
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.single()
if (data) {
profile.value = data as Profile
}
function applyAuth(auth: AuthResponse) {
setAccessToken(auth.access_token)
user.value = auth.user
profile.value = auth.profile
}
async function ensureUsernameAvailable(username: string, currentUserId?: string) {
let query = supabase
.from('profiles')
.select('id')
.eq('username', username)
.limit(1)
if (currentUserId) {
query = query.neq('id', currentUserId)
}
const { data, error } = await query
if (error) throw error
if (data?.length) {
throw new Error('这个昵称已经被使用,请换一个。')
}
}
async function login(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
if (error.message.includes('Invalid login credentials')) {
throw new Error('邮箱或密码错误')
}
if (error.message.includes('Email not confirmed')) {
throw new Error('邮箱未确认,请先查收确认邮件')
}
throw error
}
await fetchProfile(data.user.id)
user.value = data.user
}
async function register(email: string, password: string, username: string) {
await ensureUsernameAvailable(username)
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: { username },
emailRedirectTo: `${window.location.origin}/auth/confirm`,
},
})
if (error) {
if (error.message.includes('already registered')) {
throw new Error('该邮箱已被注册')
}
if (error.message.includes('Password')) {
throw new Error('密码强度不足,请使用至少6位密码')
}
throw error
}
}
async function logout() {
const { error } = await supabase.auth.signOut()
if (error) throw error
function clearAuth() {
clearAccessToken()
user.value = null
profile.value = null
}
async function updateUsername(username: string) {
if (!user.value) throw new Error('请先登录。')
async function login(email: string, password: string) {
const auth = await apiRequest<AuthResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { email, password },
})
applyAuth(auth)
}
await ensureUsernameAvailable(username, user.value.id)
async function register(email: string, password: string, username: string) {
await apiRequest('/auth/register', {
method: 'POST',
auth: false,
body: { email, password, username },
})
}
const { data, error } = await supabase
.from('profiles')
.update({ username })
.eq('id', user.value.id)
.select('*')
.single()
if (error) {
if (error.code === '23505') {
throw new Error('这个昵称已经被使用,请换一个。')
}
throw error
async function logout() {
try {
await apiRequest('/auth/logout', { method: 'POST', retry: false })
} finally {
clearAuth()
}
}
profile.value = data as Profile
async function updateUsername(username: string) {
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
method: 'PATCH',
body: { username },
})
profile.value = updatedProfile
}
async function updatePassword(password: string) {
const { error } = await supabase.auth.updateUser({ password })
if (error) throw error
const auth = await apiRequest<AuthResponse>('/auth/password', {
method: 'PATCH',
body: { password },
})
applyAuth(auth)
}
async function sendPasswordReset(email: string) {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
await apiRequest('/auth/forgot-password', {
method: 'POST',
auth: false,
body: { email },
})
if (error) {
if (error.message.includes('rate limit')) {
throw new Error('发送过于频繁,请稍后再试。')
}
throw error
}
}
async function initialize() {
const { data: { session } } = await supabase.auth.getSession()
const initialUser = session?.user ?? null
if (initialUser) await fetchProfile(initialUser.id)
user.value = initialUser
loading.value = false
if (!listenersAttached) {
window.addEventListener(AUTH_UPDATED_EVENT, event => {
applyAuth((event as CustomEvent<AuthResponse>).detail)
})
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
listenersAttached = true
}
supabase.auth.onAuthStateChange((_event, session) => {
const nextUser = session?.user ?? null
if (nextUser) {
fetchProfile(nextUser.id).then(() => {
user.value = nextUser
})
} else {
user.value = null
profile.value = null
}
})
try {
applyAuth(await refreshSession())
} catch {
clearAuth()
} finally {
loading.value = false
}
}
return {
@@ -152,7 +105,6 @@ export const useAuthStore = defineStore('auth', () => {
loading,
isLoggedIn,
isAdmin,
fetchProfile,
login,
register,
logout,
+6 -9
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { supabase } from '@/lib/supabase'
import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import type { CloudType } from '@/types/database'
export const useCloudsStore = defineStore('clouds', () => {
@@ -10,14 +10,11 @@ export const useCloudsStore = defineStore('clouds', () => {
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[]
try {
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
} finally {
loading.value = false
}
loading.value = false
}
return { cloudTypes, loading, fetchCloudTypes }
+12 -71
View File
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ApiCollection } from '@/types/api'
import type { CloudType, UserCollection } from '@/types/database'
export interface CollectionPreviewCloud {
@@ -18,27 +19,7 @@ export interface CollectionEntry extends UserCollection {
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
return error.message
}
return '图鉴收藏加载失败'
}
function toCollectionCloudMap(rows: Array<Record<string, unknown>> | null) {
return new Map(
(rows || []).map(row => [
row.id as string,
{
id: row.id as string,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
location_name: (row.location_name as string | null) ?? null,
} satisfies CollectionPreviewCloud,
]),
)
return error instanceof Error ? error.message : '图鉴收藏加载失败'
}
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
@@ -52,30 +33,18 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
const collectionLoadedForUserId = ref<string | null>(null)
const collectionError = ref('')
const collectionMap = computed(() => {
return new Map(myCollection.value.map(item => [item.cloud_type_id, item]))
})
const collectionMap = computed(() => new Map(myCollection.value.map(item => [item.cloud_type_id, item])))
const unlockedCount = computed(() => myCollection.value.length)
const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`)
const unlockPercent = computed(() => {
if (!cloudTypes.value.length) return 0
return Math.round(unlockedCount.value / cloudTypes.value.length * 100)
})
const unlockPercent = computed(() => cloudTypes.value.length
? Math.round(unlockedCount.value / cloudTypes.value.length * 100)
: 0)
async function fetchCloudTypes(force = false) {
if (cloudTypesLoaded.value && !force) return
loadingCloudTypes.value = true
try {
const { data, error } = await supabase
.from('cloud_types')
.select('*')
.order('id')
if (error) throw error
cloudTypes.value = (data || []) as CloudType[]
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
cloudTypesLoaded.value = true
} finally {
loadingCloudTypes.value = false
@@ -84,49 +53,21 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
async function fetchMyCollection(force = false) {
const userId = authStore.user?.id ?? null
if (!userId) {
myCollection.value = []
collectionLoadedForUserId.value = null
collectionError.value = ''
return
}
if (!force && collectionLoadedForUserId.value === userId) return
loadingCollection.value = true
collectionError.value = ''
try {
const { data, error } = await supabase
.from('user_collections')
.select('*')
.eq('user_id', userId)
.order('unlocked_at', { ascending: true })
if (error) throw error
const collectionRows = (data || []) as UserCollection[]
const firstCloudIds = collectionRows
.map(item => item.first_cloud_id)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
let firstCloudMap = new Map<string, CollectionPreviewCloud>()
if (firstCloudIds.length) {
const { data: firstCloudData, error: firstCloudError } = await supabase
.from('clouds')
.select('id,image_url,thumbnail_url,captured_at,created_at,location_name')
.in('id', firstCloudIds)
if (firstCloudError) throw firstCloudError
firstCloudMap = toCollectionCloudMap(firstCloudData as Array<Record<string, unknown>> | null)
}
myCollection.value = collectionRows.map(item => ({
...item,
firstCloud: item.first_cloud_id ? firstCloudMap.get(item.first_cloud_id) ?? null : null,
const rows = await apiRequest<ApiCollection[]>('/collections/me')
myCollection.value = rows.map(({ first_cloud, cloud_type: _cloudType, ...collection }) => ({
...collection,
firstCloud: first_cloud,
}))
collectionLoadedForUserId.value = userId
} catch (error) {
+70 -141
View File
@@ -1,6 +1,8 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
export interface ProfileCloudItem {
@@ -21,52 +23,36 @@ export interface ProfileCloudItem {
cloudTypeRarity: CloudType['rarity']
}
function toProfileCloud(row: Record<string, unknown>) {
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
return {
id: row.id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as ProfileCloudItem['status'],
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
} satisfies ProfileCloudItem
id: row.id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
}
}
function getSupabaseErrorCode(error: unknown) {
if (!error || typeof error !== 'object' || !('code' in error)) return null
const code = (error as { code?: unknown }).code
return typeof code === 'string' ? code : null
}
function getCloudStoragePath(publicUrl: string | null) {
if (!publicUrl) return null
try {
const url = new URL(publicUrl)
const marker = '/storage/v1/object/public/clouds/'
const markerIndex = url.pathname.indexOf(marker)
if (markerIndex === -1) return null
const path = url.pathname.slice(markerIndex + marker.length)
return path ? decodeURIComponent(path) : null
} catch {
return null
function toProfile(profile: PublicProfile): Profile {
return {
...profile,
role: 'user',
is_disabled: false,
}
}
export const useProfileStore = defineStore('profile-page', () => {
const authStore = useAuthStore()
const profilesById = ref<Record<string, Profile>>({})
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
const loadingKeys = ref<Record<string, boolean>>({})
@@ -75,23 +61,32 @@ export const useProfileStore = defineStore('profile-page', () => {
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
function getProfile(userId: string | null) {
if (!userId) return null
return profilesById.value[userId] ?? null
return userId ? profilesById.value[userId] ?? null : null
}
function getClouds(userId: string | null, isOwnProfile: boolean) {
if (!userId) return []
return cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? []
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
}
function getError(userId: string | null, isOwnProfile: boolean) {
if (!userId) return ''
return errorByKey.value[makeKey(userId, isOwnProfile)] ?? ''
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
}
function isLoaded(userId: string | null, isOwnProfile: boolean) {
if (!userId) return false
return makeKey(userId, isOwnProfile) in cloudsByKey.value
return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value
}
async function fetchAllClouds(userId: string, isOwnProfile: boolean) {
const requestOptions = { auth: isOwnProfile }
const first = await apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=1&page_size=100`, requestOptions)
if (first.total_pages <= 1) return first.items
const remaining = await Promise.all(
Array.from({ length: first.total_pages - 1 }, (_, index) =>
apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=${index + 2}&page_size=100`, requestOptions),
),
)
return [first, ...remaining].flatMap(page => page.items)
}
async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
@@ -100,34 +95,13 @@ export const useProfileStore = defineStore('profile-page', () => {
loadingKeys.value[key] = true
errorByKey.value[key] = ''
try {
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single()
if (profileError) throw profileError
profilesById.value[userId] = profile as Profile
let cloudsQuery = supabase
.from('clouds')
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
.eq('user_id', userId)
.order('captured_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
if (!isOwnProfile) {
cloudsQuery = cloudsQuery
.eq('status', 'approved')
.eq('is_hidden', false)
}
const { data: cloudRows, error: cloudsError } = await cloudsQuery
if (cloudsError) throw cloudsError
cloudsByKey.value[key] = ((cloudRows || []) as Array<Record<string, unknown>>).map(toProfileCloud)
const profilePromise = isOwnProfile && authStore.profile?.id === userId
? Promise.resolve(authStore.profile)
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
profilesById.value[userId] = profile
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
} catch (error) {
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
cloudsByKey.value[key] = []
@@ -137,8 +111,7 @@ export const useProfileStore = defineStore('profile-page', () => {
}
function isLoading(userId: string | null, isOwnProfile: boolean) {
if (!userId) return false
return !!loadingKeys.value[makeKey(userId, isOwnProfile)]
return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
}
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
@@ -146,9 +119,8 @@ export const useProfileStore = defineStore('profile-page', () => {
const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key]
if (!current) continue
cloudsByKey.value[key] = current
.map(item => (item.id === cloudId ? { ...item, ...patch } : item))
.map(item => item.id === cloudId ? { ...item, ...patch } : item)
.filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden))
}
}
@@ -158,8 +130,7 @@ export const useProfileStore = defineStore('profile-page', () => {
for (const isOwnProfile of [true, false]) {
const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key]
if (!current) continue
cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
}
}
@@ -178,80 +149,38 @@ export const useProfileStore = defineStore('profile-page', () => {
captured_at: string | null
is_hidden: boolean
}) {
const { data, error } = await supabase
.from('clouds')
.update(patch)
.eq('id', cloudId)
.eq('user_id', userId)
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
.single()
if (error) throw error
const updated = toProfileCloud(data as Record<string, unknown>)
const updated = toProfileCloud(await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
method: 'PATCH',
body: patch,
}))
patchCachedCloud(userId, cloudId, updated)
return updated
}
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.eq('id', cloudId)
.eq('user_id', userId)
.select('id')
if (error) throw error
if (!data?.length) {
throw new Error('私密状态没有写入数据库,请检查 clouds 表的 UPDATE RLS policy。')
}
await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
method: 'PATCH',
body: { is_hidden: isHidden },
})
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
}
async function deleteClouds(userId: string, cloudIds: string[]) {
if (!cloudIds.length) return 0
const { data, error } = await supabase
.from('clouds')
.delete()
.eq('user_id', userId)
.in('id', cloudIds)
.select('id,image_url,thumbnail_url')
if (error) {
if (getSupabaseErrorCode(error) === '23503') {
throw new Error('这张照片仍被图鉴收藏记录引用,请先在数据库把 user_collections.first_cloud_id 外键改为 ON DELETE SET NULL。')
}
throw error
}
const deletedIds = (data || []).map(item => item.id as string)
if (deletedIds.length !== cloudIds.length) {
throw new Error('图片没有真正从数据库删除,请检查 clouds 表的 DELETE RLS policy。')
}
const storagePaths = Array.from(new Set(
(data || [])
.flatMap(item => [
getCloudStoragePath((item.image_url as string | null) ?? null),
getCloudStoragePath((item.thumbnail_url as string | null) ?? null),
])
.filter((path): path is string => !!path),
))
if (storagePaths.length) {
const { error: storageError } = await supabase.storage
.from('clouds')
.remove(storagePaths)
if (storageError) {
throw new Error(`图片数据库记录已删除,但 Supabase Storage 文件清理失败:${storageError.message}`)
if (cloudIds.length === 1) {
await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' })
} else {
for (let offset = 0; offset < cloudIds.length; offset += 100) {
await apiRequest('/clouds/batch-delete', {
method: 'POST',
body: { ids: cloudIds.slice(offset, offset + 100) },
})
}
}
removeCachedClouds(userId, deletedIds)
return deletedIds.length
removeCachedClouds(userId, cloudIds)
return cloudIds.length
}
return {
+93
View File
@@ -0,0 +1,93 @@
import type { Cloud, CloudType, Profile, UserCollection } from '@/types/database'
export interface AuthUser {
id: string
email: string
email_verified: boolean
created_at: string
}
export interface AuthResponse {
access_token: string
token_type: 'bearer'
expires_in: number
user: AuthUser
profile: Profile
}
export interface MeResponse {
user: AuthUser
profile: Profile
}
export interface PublicProfile {
id: string
username: string
avatar_url: string | null
created_at: string
}
export interface ApiPage<T> {
items: T[]
page: number
page_size: number
total: number
total_pages: number
}
export interface ApiCloudTypeSummary {
id: number
name: string
name_en: string
rarity: CloudType['rarity']
}
export interface ApiCloudOwner {
id: string
username: string
}
export interface ApiCloud extends Cloud {
updated_at: string
cloud_type: ApiCloudTypeSummary | null
owner: ApiCloudOwner | null
cloud_type_name: string | null
cloud_type_rarity: CloudType['rarity'] | null
username: string | null
}
export interface ApiCollection extends UserCollection {
cloud_type: ApiCloudTypeSummary
first_cloud: Pick<
ApiCloud,
'id' | 'image_url' | 'thumbnail_url' | 'captured_at' | 'created_at' | 'location_name'
> | null
}
export interface UnlockedBadge {
cloud_type_id: number
cloud_name: string
cloud_name_en: string
rarity: CloudType['rarity']
unlocked_at: string
}
export interface CloudCreateResponse {
cloud: ApiCloud
unlocked_badge: UnlockedBadge | null
}
export interface AdminStats {
users: number
images: number
today_uploads: number
pending: number
approved: number
rejected: number
hidden: number
}
export interface AdminUserItem {
user: AuthUser
profile: Profile
}
-2
View File
@@ -24,8 +24,6 @@ export interface Cloud {
status: 'pending' | 'approved' | 'rejected'
is_hidden: boolean
created_at: string
cloud_types?: CloudType
profiles?: Profile
}
export interface Profile {
+65 -172
View File
@@ -4,9 +4,9 @@ import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'nai
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useProfileStore } from '@/stores/profile'
import type { AdminStats, AdminUserItem, ApiCloud, ApiPage } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
@@ -44,7 +44,6 @@ interface DashboardStats {
}
const authStore = useAuthStore()
const profileStore = useProfileStore()
const message = useMessage()
const activeTab = ref<AdminTab>('dashboard')
@@ -110,33 +109,25 @@ const cloudTypeStats = computed(() => {
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
function readJoinedOne(value: unknown) {
const rows = Array.isArray(value) ? value : value ? [value] : []
return rows[0] as Record<string, unknown> | undefined
}
function toAdminCloud(row: Record<string, unknown>): AdminCloud {
const cloudType = readJoinedOne(row.cloud_types)
const profile = readJoinedOne(row.profiles)
function toAdminCloud(row: ApiCloud): AdminCloud {
return {
id: row.id as string,
user_id: row.user_id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as CloudStatus,
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知云型',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名用户',
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型',
cloudTypeRarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名用户',
}
}
@@ -156,104 +147,30 @@ function formatCoordinate(value: number | null) {
}
function getErrorMessage(error: unknown, fallback: string) {
if (!error || typeof error !== 'object') return fallback
const payload = error as {
message?: unknown
code?: unknown
details?: unknown
hint?: unknown
}
const parts = [
typeof payload.message === 'string' ? payload.message : '',
typeof payload.code === 'string' ? `错误码:${payload.code}` : '',
typeof payload.details === 'string' ? payload.details : '',
typeof payload.hint === 'string' ? `提示:${payload.hint}` : '',
].filter(Boolean)
return parts.length ? parts.join(';') : fallback
}
function todayIsoStart() {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date.toISOString()
}
async function countProfiles() {
const { count, error } = await supabase
.from('profiles')
.select('*', { head: true, count: 'exact' })
if (error) throw error
return count || 0
}
async function countClouds(filters: {
status?: CloudStatus
isHidden?: boolean
createdAfter?: string
} = {}) {
let query = supabase
.from('clouds')
.select('*', { head: true, count: 'exact' })
if (filters.status) query = query.eq('status', filters.status)
if (typeof filters.isHidden === 'boolean') query = query.eq('is_hidden', filters.isHidden)
if (filters.createdAfter) query = query.gte('created_at', filters.createdAfter)
const { count, error } = await query
if (error) throw error
return count || 0
return error instanceof Error ? error.message : fallback
}
async function fetchStats() {
const [
userCount,
imageCount,
todayUploads,
pendingCount,
approvedCount,
rejectedCount,
hiddenCount,
] = await Promise.all([
countProfiles(),
countClouds(),
countClouds({ createdAfter: todayIsoStart() }),
countClouds({ status: 'pending' }),
countClouds({ status: 'approved' }),
countClouds({ status: 'rejected' }),
countClouds({ isHidden: true }),
])
const stats = await apiRequest<AdminStats>('/admin/stats')
dashboardStats.value = {
users: userCount,
images: imageCount,
todayUploads,
pending: pendingCount,
approved: approvedCount,
rejected: rejectedCount,
hidden: hiddenCount,
users: stats.users,
images: stats.images,
todayUploads: stats.today_uploads,
pending: stats.pending,
approved: stats.approved,
rejected: stats.rejected,
hidden: stats.hidden,
}
}
async function fetchUsers() {
const { data, error } = await supabase
.from('profiles')
.select('id,username,avatar_url,role,is_disabled,created_at')
.order('created_at', { ascending: false })
.limit(100)
if (error) throw error
users.value = (data || []) as Profile[]
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
users.value = result.items.map(item => item.profile)
}
async function fetchImages() {
const { data, error } = await supabase
.from('clouds')
.select('id,user_id,cloud_type_id,custom_cloud_type,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,cloud_types(name,rarity),profiles(username)')
.order('created_at', { ascending: false })
.limit(120)
if (error) throw error
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
images.value = result.items.map(toAdminCloud)
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
}
@@ -327,6 +244,12 @@ function patchImages(ids: string[], patch: Partial<AdminCloud>) {
}
}
function chunkIds(ids: string[]) {
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
ids.slice(index * 100, (index + 1) * 100),
)
}
async function updateCloudStatus(ids: string[], status: CloudStatus) {
if (!ids.length) return
@@ -334,18 +257,11 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
loadError.value = ''
try {
let query = supabase
.from('clouds')
.update({ status })
.select('id')
query = ids.length === 1 ? query.eq('id', ids[0]) : query.in('id', ids)
const { data, error } = await query
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
for (const chunk of chunkIds(ids)) {
await apiRequest('/admin/clouds/status', {
method: 'PATCH',
body: { ids: chunk, status },
})
}
patchImages(ids, { status })
@@ -369,15 +285,11 @@ async function updateImageVisibility(ids: string[], isHidden: boolean) {
loadError.value = ''
try {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.in('id', ids)
.select('id')
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
for (const chunk of chunkIds(ids)) {
await apiRequest('/admin/clouds/visibility', {
method: 'PATCH',
body: { ids: chunk, is_hidden: isHidden },
})
}
patchImages(ids, { is_hidden: isHidden })
@@ -412,16 +324,11 @@ async function deleteImages(clouds: AdminCloud[]) {
try {
const idsToDelete = clouds.map(cloud => cloud.id)
const cloudsByUser = new Map<string, string[]>()
for (const cloud of clouds) {
const current = cloudsByUser.get(cloud.user_id) || []
current.push(cloud.id)
cloudsByUser.set(cloud.user_id, current)
}
for (const [userId, cloudIds] of cloudsByUser.entries()) {
await profileStore.deleteClouds(userId, cloudIds)
for (const chunk of chunkIds(idsToDelete)) {
await apiRequest('/admin/clouds/batch-delete', {
method: 'POST',
body: { ids: chunk },
})
}
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
@@ -455,18 +362,11 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
loadError.value = ''
try {
const { data, error } = await supabase
.from('profiles')
.update({ role })
.eq('id', user.id)
.select('id,role')
if (error) throw error
if (!data?.length) {
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { role },
})
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success('用户角色已更新')
} catch (error) {
const text = getErrorMessage(error, '用户角色更新失败')
@@ -488,18 +388,11 @@ async function toggleUserDisabled(user: Profile) {
try {
const nextDisabled = !user.is_disabled
const { data, error } = await supabase
.from('profiles')
.update({ is_disabled: nextDisabled })
.eq('id', user.id)
.select('id,is_disabled')
if (error) throw error
if (!data?.length) {
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { is_disabled: nextDisabled },
})
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) {
const text = getErrorMessage(error, '用户状态更新失败')
@@ -522,7 +415,7 @@ onMounted(loadAdminData)
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-sky-700">Admin Console</p>
<h1 class="mt-3 text-4xl font-bold text-slate-950">管理后台</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都直接落到 Supabase。
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有操作都由 FastAPI 服务统一处理。
</p>
</div>
+11 -10
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
import { createDetachedAuthClient, resolveEmailAuthCallback } from '@/lib/authEmail'
import { apiRequest } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const state = ref<'loading' | 'success' | 'failed'>('loading')
@@ -22,18 +23,18 @@ function startCountdown() {
onMounted(async () => {
try {
const confirmClient = createDetachedAuthClient()
const result = await resolveEmailAuthCallback({
client: confirmClient,
allowedTypes: ['signup', 'email'],
})
if (!result.ok) {
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
state.value = 'failed'
return
}
await confirmClient.auth.signOut()
await apiRequest('/auth/confirm-email', {
method: 'POST',
auth: false,
body: { token },
})
window.history.replaceState(null, '', window.location.pathname)
state.value = 'success'
startCountdown()
} catch {
+24 -38
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
import { resolveEmailAuthCallback } from '@/lib/authEmail'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const password = ref('')
@@ -13,10 +13,11 @@ const error = ref('')
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
const loading = ref(false)
const countdown = ref(5)
const resetToken = ref('')
let countdownTimer: ReturnType<typeof setInterval> | null = null
const canSubmit = computed(() =>
state.value === 'ready' && password.value.length >= 6 && password.value === confirmPassword.value,
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
)
function showInvalidRecoveryLink() {
@@ -26,19 +27,12 @@ function showInvalidRecoveryLink() {
function getResetErrorMessage(value: unknown) {
const message = value instanceof Error ? value.message : ''
if (
message.includes('Auth session missing')
|| message.includes('session_not_found')
|| message.includes('refresh_token_not_found')
|| message.includes('Invalid Refresh Token')
|| message.includes('otp_expired')
|| message.includes('expired')
) {
if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
return '密码重置链接无效或已过期,请重新发送邮件。'
}
if (message.includes('Password should be at least')) {
return '新密码至少需要 6 位。'
if (message.includes('8')) {
return '新密码至少需要 8 位。'
}
return message || '密码重置失败,请稍后重试。'
@@ -49,31 +43,27 @@ function startCountdown() {
countdown.value--
if (countdown.value <= 0) {
if (countdownTimer) clearInterval(countdownTimer)
router.push('/')
router.push('/login')
}
}, 1000)
}
async function initializeRecoverySession() {
const result = await resolveEmailAuthCallback({
client: supabase,
allowedTypes: ['recovery'],
})
if (!result.ok || !result.session?.user) {
error.value = getResetErrorMessage(result.ok ? '链接无效或已过期。' : result.error)
state.value = 'invalid'
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
showInvalidRecoveryLink()
return
}
resetToken.value = token
state.value = 'ready'
}
async function handleResetPassword() {
error.value = ''
if (password.value.length < 6) {
error.value = '新密码至少需要 6 位。'
if (password.value.length < 8) {
error.value = '新密码至少需要 8 位。'
return
}
if (password.value !== confirmPassword.value) {
@@ -81,17 +71,13 @@ async function handleResetPassword() {
return
}
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
if (sessionError || !session?.user) {
showInvalidRecoveryLink()
return
}
loading.value = true
try {
const { error: updateError } = await supabase.auth.updateUser({ password: password.value })
if (updateError) throw updateError
await apiRequest('/auth/reset-password', {
method: 'POST',
auth: false,
body: { token: resetToken.value, password: password.value },
})
window.history.replaceState(null, '', window.location.pathname)
countdown.value = 5
state.value = 'success'
@@ -123,12 +109,12 @@ onUnmounted(() => {
v-if="state === 'success'"
status="success"
title="密码已重置"
description="你已使用新密码完成更新,正在返回地图页。"
description="你已使用新密码完成更新,现在可以重新登录。"
>
<template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转地图页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/')">进入地图</NButton>
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
</div>
</template>
</NResult>
@@ -162,7 +148,7 @@ onUnmounted(() => {
required
show-password-on="click"
autocomplete="new-password"
placeholder="至少 6 位"
placeholder="至少 8 位"
/>
</NFormItem>
+16 -44
View File
@@ -4,9 +4,10 @@ import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database'
interface CloudGalleryItem {
@@ -82,49 +83,20 @@ function openGalleryDetail(item: CloudGalleryItem) {
}
async function loadGallery(typeId: number) {
const galleryQuery = supabase
.from('clouds')
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,profiles(username)')
.eq('cloud_type_id', typeId)
.eq('status', 'approved')
.eq('is_hidden', false)
.order('captured_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
.limit(24)
const countQuery = supabase
.from('clouds')
.select('*', { head: true, count: 'exact' })
.eq('cloud_type_id', typeId)
.eq('status', 'approved')
.eq('is_hidden', false)
const [{ data: galleryData, error: galleryError }, { count, error: countError }] = await Promise.all([
galleryQuery,
countQuery,
])
if (galleryError) throw galleryError
if (countError) throw countError
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => {
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
const profile = profiles[0] as Record<string, unknown> | undefined
return {
id: row.id as string,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
profiles: profile ? { username: profile.username as string } : null,
} satisfies CloudGalleryItem
})
publicCount.value = count || 0
const result = await apiRequest<ApiPage<ApiCloud>>(`/cloud-types/${typeId}/clouds?page=1&page_size=24`, { auth: false })
gallery.value = result.items.map(row => ({
id: row.id,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
profiles: row.username ? { username: row.username } : null,
}))
publicCount.value = result.total
}
async function loadPage() {
+26 -120
View File
@@ -5,11 +5,12 @@ import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useProfileStore } from '@/stores/profile'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database'
interface GalleryCloud {
@@ -87,135 +88,40 @@ function formatCoordinate(value: number | null) {
const normalizedSearch = computed(() => searchQuery.value.trim())
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
function sanitizeSearchTerm(term: string) {
return term.replace(/[(),*%]/g, ' ').replace(/\s+/g, ' ').trim()
}
function getMatchedCloudTypeIds(term: string) {
const lowerTerm = term.toLocaleLowerCase('zh-CN')
return cloudsStore.cloudTypes
.filter(type => {
return type.name.toLocaleLowerCase('zh-CN').includes(lowerTerm) ||
type.name_en.toLocaleLowerCase('zh-CN').includes(lowerTerm)
})
.map(type => type.id)
}
async function fetchUserIdsBySearch(term: string) {
const sanitized = sanitizeSearchTerm(term)
if (!sanitized) return []
const { data, error } = await supabase
.from('profiles')
.select('id')
.ilike('username', `%${sanitized}%`)
.limit(100)
if (error) throw error
return ((data || []) as Array<{ id: string }>).map(profile => profile.id)
}
function toGalleryCloud(row: Record<string, unknown>) {
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
const profile = profiles[0] as Record<string, unknown> | undefined
function toGalleryCloud(row: ApiCloud) {
return {
id: row.id as string,
user_id: row.user_id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as GalleryCloud['status'],
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名',
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
location_name: row.location_name,
description: row.description,
latitude: row.latitude,
longitude: row.longitude,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名',
} satisfies GalleryCloud
}
async function resolveSearchFilters() {
const search = normalizedSearch.value
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
if (isUserSearch.value && !usernameTerm) return null
if (search && !isUserSearch.value && !cloudTypeTerm) return null
let userIds: string[] = []
if (usernameTerm) {
userIds = await fetchUserIdsBySearch(usernameTerm)
if (userIds.length === 0) return null
}
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
return { usernameTerm, cloudTypeTerm, userIds, matchedCloudTypeIds }
}
const FULL_SELECT = 'id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)'
function buildFilteredQuery(selectStr: string, options?: { count?: 'exact'; head?: boolean }) {
let query = supabase
.from('clouds')
.select(selectStr, options as any)
.eq('status', 'approved')
.eq('is_hidden', false)
if (selectedTypeId.value !== 'all') {
query = query.eq('cloud_type_id', selectedTypeId.value)
}
return query
}
async function loadPage(page: number) {
loading.value = true
loadError.value = ''
try {
const filters = await resolveSearchFilters()
if (filters === null) {
galleryItems.value = []
totalCount.value = 0
currentPage.value = 1
loading.value = false
return
}
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
if (normalizedSearch.value) params.set('search', normalizedSearch.value)
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true })
let dataQuery = buildFilteredQuery(FULL_SELECT)
.order('created_at', { ascending: false })
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1)
if (filters.usernameTerm) {
countQuery = countQuery.in('user_id', filters.userIds)
dataQuery = dataQuery.in('user_id', filters.userIds)
} else if (filters.cloudTypeTerm) {
if (filters.matchedCloudTypeIds.length) {
const orFilter = `cloud_type_id.in.(${filters.matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${filters.cloudTypeTerm}*`
countQuery = countQuery.or(orFilter)
dataQuery = dataQuery.or(orFilter)
} else {
countQuery = countQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
dataQuery = dataQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
}
}
const [{ count }, { data, error }] = await Promise.all([countQuery, dataQuery])
if (error) throw error
totalCount.value = count ?? 0
galleryItems.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toGalleryCloud)
const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
totalCount.value = result.total
galleryItems.value = result.items.map(toGalleryCloud)
currentPage.value = page
} catch (error) {
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
+23 -30
View File
@@ -3,9 +3,10 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { loadAMap } from '@/lib/amap'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud } from '@/types/api'
import { NIcon } from 'naive-ui'
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
@@ -212,44 +213,36 @@ function hideHeader() {
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
}
function toCloudMarker(row: Record<string, unknown>): CloudMarkerData {
const ct = row.cloud_types as Record<string, unknown> | null
const pf = row.profiles as Record<string, unknown> | null
function toCloudMarker(row: ApiCloud): CloudMarkerData {
return {
id: row.id as string,
id: row.id,
latitude: row.latitude as number,
longitude: row.longitude as number,
imageUrl: row.image_url as string,
thumbnailUrl: (row.thumbnail_url as string | null) ?? null,
locationName: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
cloudTypeName: (ct?.name as string) || (row.custom_cloud_type as string) || '未知',
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
username: (pf?.username as string) || '匿名',
capturedAt: (row.captured_at as string) || (row.created_at as string),
createdAt: row.created_at as string,
imageUrl: row.image_url,
thumbnailUrl: row.thumbnail_url,
locationName: row.location_name,
description: row.description,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
rarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名',
capturedAt: row.captured_at || row.created_at,
createdAt: row.created_at,
}
}
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
const { data, error } = await supabase
.from('clouds')
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
.eq('status', 'approved')
.eq('is_hidden', false)
.not('latitude', 'is', null)
.not('longitude', 'is', null)
.gte(field, start.toISOString())
.lt(field, end.toISOString())
.order(field, { ascending: true })
.limit(1000)
if (error) {
statusText.value = `查询失败: ${error.message}`
const params = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString(),
time_field: field,
})
try {
const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
return rows.map(toCloudMarker)
} catch (error) {
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
return []
}
return ((data || []) as Array<Record<string, unknown>>).map(toCloudMarker)
}
async function loadRealtimeClouds() {
+3 -3
View File
@@ -54,8 +54,8 @@ async function savePassword() {
error.value = ''
success.value = ''
if (newPassword.value.length < 6) {
error.value = '新密码至少需要 6 位。'
if (newPassword.value.length < 8) {
error.value = '新密码至少需要 8 位。'
return
}
if (newPassword.value !== confirmNewPassword.value) {
@@ -145,7 +145,7 @@ async function savePassword() {
<div>
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
修改后下次登录需要使用新密码。建议使用至少 6 位且不易猜测的密码。
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
</p>
</div>
<div class="grid gap-3">