refactor: switch to fastapi backend

This commit is contained in:
2026-07-18 14:48:45 +08:00
parent 9fd47e01f5
commit 613452b7fe
22 changed files with 701 additions and 1342 deletions
+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 ## 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`) - 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` - AMap loaded lazily via `src/lib/amap.ts` with type declarations in `src/types/amap.d.ts`
- UI language is Chinese (zh-CN) - 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 ## Directory Map
| Path | Purpose | | 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/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/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
| `src/components/layout/` | AppHeader (top nav bar with auth state) | | `src/components/layout/` | AppHeader (top nav bar with auth state) |
| `src/components/profile/` | ContributionHeatmap | | `src/components/profile/` | ContributionHeatmap |
| `src/style.css` | Global visual system hooks: shared button/card utility classes, base typography, body background | | `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/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 ## Routes
| Path | View | Auth Required | | Path | View | Auth Required |
| --- | --- | :---: | | --- | --- | :---: |
| `/` | MapView | No | | `/` | 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 | | `/upload` | UploadView | Yes |
| `/encyclopedia` | EncyclopediaView | No | | `/encyclopedia` | EncyclopediaView | No |
| `/encyclopedia/:id` | CloudTypeView | No | | `/encyclopedia/:id` | CloudTypeView | No |
@@ -46,39 +46,52 @@
| `/profile/:id` | ProfileView (public) | No | | `/profile/:id` | ProfileView (public) | No |
| `/admin` | AdminView | Yes (admin) | | `/admin` | AdminView | Yes (admin) |
| `/403` | ForbiddenView | No | | `/403` | ForbiddenView | No |
| `/401` | AuthRequiredView | No |
| `/:pathMatch(.*)*` | NotFoundView | 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` - SEO meta tags applied per-route via `lib/seo.ts` in `router.afterEach`
## Auth Flow ## 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. - **`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.
- **Login sets `user.value` explicitly**: `login()` extracts `data.user` from `signInWithPassword` and assigns it to the store directly, rather than relying on `onAuthStateChange`. - Access tokens live in memory only. Refresh cookies are managed by the backend and sent with `credentials: 'include'`.
- **Profile is auto-created by DB trigger** (`handle_new_user` on `auth.users`), not by the frontend. - `src/lib/api.ts` performs one automatic refresh-and-retry after an authenticated request receives `401`.
- Auth error messages are translated to Chinese in the store. - API auth state changes are synchronized back to the Pinia store through `opencloud:auth-updated` and `opencloud:auth-expired` window events.
- `register()` checks username uniqueness before calling `signUp()` — username is passed via `options.data.username`. - 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 ## Stores
- **auth** — User session, profile, login/register/logout, username/password update, password reset. `initialize()` must be called before app mounts. - **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`. - **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. - Backend project lives in the sibling directory `../opencloud-backend`.
- All tables have RLS enabled. Check `plan.md` section 10 for the full schema and RLS policies. - Default development API URL is `http://localhost:8000/api/v1`; set `VITE_API_URL` for deployed environments.
- Storage bucket `clouds` is public read, authenticated upload. - Frontend route guards are for navigation UX only. The backend enforces ownership, authentication, admin roles, and disabled-account rules.
- Profile `role` field (`user`/`admin`) controls admin access — checked in route guard, not in JWT metadata. - Images are uploaded as multipart form data. The backend stores originals, creates thumbnails, blurs coordinates, writes database records, and atomically unlocks badges.
- `user_collections` tracks encyclopedia unlocks with `first_cloud_id` FK to `clouds`.
## 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 ## Gallery Pagination
- Page-based navigation (50 items/page), not infinite scroll. - Page-based navigation (50 items/page), not infinite scroll.
- `resolveSearchFilters()` pre-fetches user IDs or cloud type IDs before the main query. - The backend resolves cloud-type/custom-type searches and `@username` searches.
- `buildFilteredQuery()` returns a chainable query builder; `loadPage()` forks it into a `count` query and a `data` query that run in parallel via `Promise.all`. - Each response includes `items`, `total`, `page`, `page_size`, and `total_pages`.
- Search debounced at 250ms. - Search debounced at 250ms.
## Map Timeline ## Map Timeline
@@ -89,7 +102,8 @@
## Upload Flow ## 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. - `UploadView` is the full-page batch uploader. `QuickUploadModal` is a single-image shortcut from the map page.
## Build & Deploy ## Build & Deploy
@@ -100,22 +114,16 @@
## Environment Variables ## Environment Variables
Required (app won't start without):
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_PUBLISHABLE_KEY`
Required for map features: Required for map features:
- `VITE_AMAP_KEY` - `VITE_AMAP_KEY`
Optional: 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`): 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.
- `OPENAI_API_KEY`, `OPENWEATHERMAP_API_KEY`
## Naive UI ## 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. - 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. - 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 OAuth — email/password only, email confirmation required
- No AI cloud identification — manual type selection - No AI cloud identification — manual type selection
- AMap only (China-focused), no Mapbox fallback yet - 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", "version": "0.0.0",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@supabase/supabase-js": "^2.106.1",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -506,90 +505,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@tailwindcss/node": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.0.tgz", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.0.tgz",
@@ -1442,15 +1357,6 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT" "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": { "node_modules/is-what": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
@@ -2035,7 +1941,9 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "dev": true,
"license": "0BSD",
"optional": true
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
-1
View File
@@ -10,7 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@supabase/supabase-js": "^2.106.1",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
+80 -285
View File
@@ -1,6 +1,8 @@
import { ref } from 'vue' 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 { useProfileStore } from '@/stores/profile'
import type { CloudCreateResponse } from '@/types/api'
import type { CloudType } from '@/types/database' import type { CloudType } from '@/types/database'
export interface UploadItem { export interface UploadItem {
@@ -32,8 +34,6 @@ export interface UploadResult {
} }
let nextId = 0 let nextId = 0
const THUMBNAIL_MAX_EDGE = 640
const THUMBNAIL_QUALITY = 0.72
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> { function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => { 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 { function extractExifDate(buffer: ArrayBuffer): string | null {
const view = new DataView(buffer) const view = new DataView(buffer)
if (view.getUint16(0, false) !== 0xffd8) return null if (view.getUint16(0, false) !== 0xffd8) return null
@@ -146,23 +68,20 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
const tiffStart = exifStart + 6 const tiffStart = exifStart + 6
const isLE = view.getUint16(tiffStart, false) === 0x4949 const isLE = view.getUint16(tiffStart, false) === 0x4949
const ifd0Offset = view.getUint32(tiffStart + 4, isLE) const ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE)
const ifdStart = tiffStart + ifd0Offset
const numEntries = view.getUint16(ifdStart, isLE) const numEntries = view.getUint16(ifdStart, isLE)
for (let i = 0; i < numEntries; i++) { for (let i = 0; i < numEntries; i++) {
const entryStart = ifdStart + 2 + i * 12 const entryStart = ifdStart + 2 + i * 12
if (entryStart + 12 > buffer.byteLength) break if (entryStart + 12 > buffer.byteLength) break
const tag = view.getUint16(entryStart, isLE) if (view.getUint16(entryStart, isLE) !== 0x9003) continue
if (tag === 0x9003) {
const dataOffset = view.getUint32(entryStart + 8, isLE) const dataOffset = view.getUint32(entryStart + 8, isLE)
const dateStr = Array.from({ length: 20 }, (_, j) => const dateStr = Array.from({ length: 20 }, (_, j) =>
String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)), String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)),
).join('') ).join('')
const iso = dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3') const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3'))
const d = new Date(iso) if (!isNaN(date.getTime())) return date.toISOString()
if (!isNaN(d.getTime())) return d.toISOString()
}
} }
} }
offset += length - 2 offset += length - 2
@@ -170,51 +89,12 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
return null return null
} }
async function fetchUnlockedTypeIds(userId: string) { function appendOptional(form: FormData, key: string, value: string | number | null) {
const { data, error } = await supabase if (value !== null && value !== '') form.append(key, String(value))
.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)
} }
export function useUpload() { export function useUpload() {
const authStore = useAuthStore()
const profileStore = useProfileStore() const profileStore = useProfileStore()
const items = ref<UploadItem[]>([]) const items = ref<UploadItem[]>([])
const uploading = ref(false) const uploading = ref(false)
@@ -223,15 +103,14 @@ export function useUpload() {
const totalItems = ref(0) const totalItems = ref(0)
async function addFiles(files: File[]) { 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) { for (const file of imageFiles) {
let capturedAt = new Date().toISOString() let capturedAt = new Date().toISOString()
try { try {
const buffer = await readFileAsArrayBuffer(file) const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
const exifDate = extractExifDate(buffer)
if (exifDate) capturedAt = exifDate if (exifDate) capturedAt = exifDate
} catch { } catch {
// fallback to now // 无 EXIF 时使用当前时间。
} }
items.value.push({ items.value.push({
@@ -252,182 +131,86 @@ export function useUpload() {
} }
function removeItem(id: string) { function removeItem(id: string) {
const idx = items.value.findIndex(i => i.id === id) const index = items.value.findIndex(item => item.id === id)
if (idx !== -1) { if (index === -1) return
URL.revokeObjectURL(items.value[idx].preview) URL.revokeObjectURL(items.value[index].preview)
items.value.splice(idx, 1) items.value.splice(index, 1)
}
} }
function clearAll() { function clearAll() {
for (const item of items.value) { items.value.forEach(item => URL.revokeObjectURL(item.preview))
URL.revokeObjectURL(item.preview)
}
items.value = [] items.value = []
} }
function validateItem(item: UploadItem): boolean { function validateItem(item: UploadItem) {
const errors: Record<string, string> = {} const errors: Record<string, string> = {}
if (!item.cloudCategoryId) { if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
errors.cloudCategory = '请选择类别'
}
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) { if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
errors.cloudCategory = '请输入自定义云型名称' errors.cloudCategory = '请输入自定义云型名称'
} }
if (!item.capturedAt) { if (!item.capturedAt) errors.capturedAt = '请选择拍摄时间'
errors.capturedAt = '请选择拍摄时间'
}
const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude) const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude)
const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude) const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude)
if (hasLat && !hasLng) { if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写'
errors.longitude = '经纬度必须同时填写' if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写'
}
if (hasLng && !hasLat) {
errors.latitude = '经纬度必须同时填写'
}
item.errors = errors item.errors = errors
return Object.keys(errors).length === 0 return Object.keys(errors).length === 0
} }
function validateAll(): boolean { function validateAll() {
let allValid = true return items.value.map(validateItem).every(Boolean)
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<UploadResult> { async function uploadAll(): Promise<UploadResult> {
if (!validateAll()) { if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
return { ok: false, unlockedBadges: [] }
}
uploading.value = true uploading.value = true
totalItems.value = items.value.length totalItems.value = items.value.length
currentItemIndex.value = 0 currentItemIndex.value = 0
overallProgress.value = 0 overallProgress.value = 0
const badges = new Map<number, UnlockedBadge>()
const userId = (await supabase.auth.getUser()).data.user?.id
if (!userId) {
uploading.value = false
return { ok: false, unlockedBadges: [] }
}
try { try {
let unlockedTypeIds = new Set<number>() for (let index = 0; index < items.value.length; index++) {
try { const item = items.value[index]
unlockedTypeIds = await fetchUnlockedTypeIds(userId) currentItemIndex.value = index + 1
} catch { overallProgress.value = Math.round(index / items.value.length * 100)
unlockedTypeIds = new Set<number>()
}
const newlyUnlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }> = [] const form = new FormData()
form.append('image', item.file)
for (let i = 0; i < items.value.length; i++) { if (item.cloudCategoryId === 'other') {
const item = items.value[i] form.append('custom_cloud_type', item.customCloudType.trim())
currentItemIndex.value = i + 1 } else {
form.append('cloud_type_id', String(item.cloudCategoryId))
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.
}
} }
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) { clearAll()
URL.revokeObjectURL(item.preview) profileStore.invalidateUser(authStore.user.id)
} return { ok: true, unlockedBadges: [...badges.values()] }
items.value = []
profileStore.invalidateUser(userId)
return {
ok: true,
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
}
} catch { } catch {
return { ok: false, unlockedBadges: [] } return { ok: false, unlockedBadges: [] }
} finally { } 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 { defineStore } from 'pinia'
import { ref, computed } from 'vue' import {
import { supabase } from '@/lib/supabase' AUTH_EXPIRED_EVENT,
import type { User } from '@supabase/supabase-js' AUTH_UPDATED_EVENT,
apiRequest,
clearAccessToken,
refreshSession,
setAccessToken,
} from '@/lib/api'
import type { AuthResponse, AuthUser } from '@/types/api'
import type { Profile } from '@/types/database' import type { Profile } from '@/types/database'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null) const user = ref<AuthUser | null>(null)
const profile = ref<Profile | null>(null) const profile = ref<Profile | null>(null)
const loading = ref(true) const loading = ref(true)
let listenersAttached = false
const isLoggedIn = computed(() => !!user.value) const isLoggedIn = computed(() => !!user.value)
const isAdmin = computed(() => profile.value?.role === 'admin') const isAdmin = computed(() => profile.value?.role === 'admin')
async function fetchProfile(userId?: string) { function applyAuth(auth: AuthResponse) {
const id = userId ?? user.value?.id setAccessToken(auth.access_token)
if (!id) return user.value = auth.user
const { data } = await supabase profile.value = auth.profile
.from('profiles')
.select('*')
.eq('id', id)
.single()
if (data) {
profile.value = data as Profile
}
} }
async function ensureUsernameAvailable(username: string, currentUserId?: string) { function clearAuth() {
let query = supabase clearAccessToken()
.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
user.value = null user.value = null
profile.value = null profile.value = null
} }
async function updateUsername(username: string) { async function login(email: string, password: string) {
if (!user.value) throw new Error('请先登录。') 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 async function logout() {
.from('profiles') try {
.update({ username }) await apiRequest('/auth/logout', { method: 'POST', retry: false })
.eq('id', user.value.id) } finally {
.select('*') clearAuth()
.single()
if (error) {
if (error.code === '23505') {
throw new Error('这个昵称已经被使用,请换一个。')
}
throw error
} }
}
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) { async function updatePassword(password: string) {
const { error } = await supabase.auth.updateUser({ password }) const auth = await apiRequest<AuthResponse>('/auth/password', {
if (error) throw error method: 'PATCH',
body: { password },
})
applyAuth(auth)
} }
async function sendPasswordReset(email: string) { async function sendPasswordReset(email: string) {
const { error } = await supabase.auth.resetPasswordForEmail(email, { await apiRequest('/auth/forgot-password', {
redirectTo: `${window.location.origin}/auth/reset-password`, method: 'POST',
auth: false,
body: { email },
}) })
if (error) {
if (error.message.includes('rate limit')) {
throw new Error('发送过于频繁,请稍后再试。')
}
throw error
}
} }
async function initialize() { async function initialize() {
const { data: { session } } = await supabase.auth.getSession() if (!listenersAttached) {
const initialUser = session?.user ?? null window.addEventListener(AUTH_UPDATED_EVENT, event => {
if (initialUser) await fetchProfile(initialUser.id) applyAuth((event as CustomEvent<AuthResponse>).detail)
user.value = initialUser })
loading.value = false window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
listenersAttached = true
}
supabase.auth.onAuthStateChange((_event, session) => { try {
const nextUser = session?.user ?? null applyAuth(await refreshSession())
if (nextUser) { } catch {
fetchProfile(nextUser.id).then(() => { clearAuth()
user.value = nextUser } finally {
}) loading.value = false
} else { }
user.value = null
profile.value = null
}
})
} }
return { return {
@@ -152,7 +105,6 @@ export const useAuthStore = defineStore('auth', () => {
loading, loading,
isLoggedIn, isLoggedIn,
isAdmin, isAdmin,
fetchProfile,
login, login,
register, register,
logout, logout,
+6 -9
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { supabase } from '@/lib/supabase' import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import type { CloudType } from '@/types/database' import type { CloudType } from '@/types/database'
export const useCloudsStore = defineStore('clouds', () => { export const useCloudsStore = defineStore('clouds', () => {
@@ -10,14 +10,11 @@ export const useCloudsStore = defineStore('clouds', () => {
async function fetchCloudTypes() { async function fetchCloudTypes() {
if (cloudTypes.value.length > 0) return if (cloudTypes.value.length > 0) return
loading.value = true loading.value = true
const { data } = await supabase try {
.from('cloud_types') cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
.select('*') } finally {
.order('id') loading.value = false
if (data) {
cloudTypes.value = data as CloudType[]
} }
loading.value = false
} }
return { cloudTypes, loading, fetchCloudTypes } return { cloudTypes, loading, fetchCloudTypes }
+12 -71
View File
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase' import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { ApiCollection } from '@/types/api'
import type { CloudType, UserCollection } from '@/types/database' import type { CloudType, UserCollection } from '@/types/database'
export interface CollectionPreviewCloud { export interface CollectionPreviewCloud {
@@ -18,27 +19,7 @@ export interface CollectionEntry extends UserCollection {
} }
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message return error instanceof Error ? 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,
]),
)
} }
export const useEncyclopediaStore = defineStore('encyclopedia', () => { export const useEncyclopediaStore = defineStore('encyclopedia', () => {
@@ -52,30 +33,18 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
const collectionLoadedForUserId = ref<string | null>(null) const collectionLoadedForUserId = ref<string | null>(null)
const collectionError = ref('') const collectionError = ref('')
const collectionMap = computed(() => { const collectionMap = computed(() => new Map(myCollection.value.map(item => [item.cloud_type_id, item])))
return new Map(myCollection.value.map(item => [item.cloud_type_id, item]))
})
const unlockedCount = computed(() => myCollection.value.length) const unlockedCount = computed(() => myCollection.value.length)
const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`) const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`)
const unlockPercent = computed(() => { const unlockPercent = computed(() => cloudTypes.value.length
if (!cloudTypes.value.length) return 0 ? Math.round(unlockedCount.value / cloudTypes.value.length * 100)
return Math.round(unlockedCount.value / cloudTypes.value.length * 100) : 0)
})
async function fetchCloudTypes(force = false) { async function fetchCloudTypes(force = false) {
if (cloudTypesLoaded.value && !force) return if (cloudTypesLoaded.value && !force) return
loadingCloudTypes.value = true loadingCloudTypes.value = true
try { try {
const { data, error } = await supabase cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
.from('cloud_types')
.select('*')
.order('id')
if (error) throw error
cloudTypes.value = (data || []) as CloudType[]
cloudTypesLoaded.value = true cloudTypesLoaded.value = true
} finally { } finally {
loadingCloudTypes.value = false loadingCloudTypes.value = false
@@ -84,49 +53,21 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
async function fetchMyCollection(force = false) { async function fetchMyCollection(force = false) {
const userId = authStore.user?.id ?? null const userId = authStore.user?.id ?? null
if (!userId) { if (!userId) {
myCollection.value = [] myCollection.value = []
collectionLoadedForUserId.value = null collectionLoadedForUserId.value = null
collectionError.value = '' collectionError.value = ''
return return
} }
if (!force && collectionLoadedForUserId.value === userId) return if (!force && collectionLoadedForUserId.value === userId) return
loadingCollection.value = true loadingCollection.value = true
collectionError.value = '' collectionError.value = ''
try { try {
const { data, error } = await supabase const rows = await apiRequest<ApiCollection[]>('/collections/me')
.from('user_collections') myCollection.value = rows.map(({ first_cloud, cloud_type: _cloudType, ...collection }) => ({
.select('*') ...collection,
.eq('user_id', userId) firstCloud: first_cloud,
.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,
})) }))
collectionLoadedForUserId.value = userId collectionLoadedForUserId.value = userId
} catch (error) { } catch (error) {
+70 -141
View File
@@ -1,6 +1,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { defineStore } from 'pinia' 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' import type { CloudType, Profile } from '@/types/database'
export interface ProfileCloudItem { export interface ProfileCloudItem {
@@ -21,52 +23,36 @@ export interface ProfileCloudItem {
cloudTypeRarity: CloudType['rarity'] cloudTypeRarity: CloudType['rarity']
} }
function toProfileCloud(row: Record<string, unknown>) { function toProfileCloud(row: ApiCloud): ProfileCloudItem {
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
return { return {
id: row.id as string, id: row.id,
cloud_type_id: (row.cloud_type_id as number | null) ?? null, cloud_type_id: row.cloud_type_id,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null, custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url as string, image_url: row.image_url,
thumbnail_url: (row.thumbnail_url as string | null) ?? null, thumbnail_url: row.thumbnail_url,
latitude: (row.latitude as number | null) ?? null, latitude: row.latitude,
longitude: (row.longitude as number | null) ?? null, longitude: row.longitude,
location_name: (row.location_name as string | null) ?? null, location_name: row.location_name,
description: (row.description as string | null) ?? null, description: row.description,
captured_at: (row.captured_at as string | null) ?? null, captured_at: row.captured_at,
created_at: row.created_at as string, created_at: row.created_at,
status: row.status as ProfileCloudItem['status'], status: row.status,
is_hidden: (row.is_hidden as boolean) ?? false, is_hidden: row.is_hidden,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知', cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common', cloudTypeRarity: row.cloud_type_rarity || 'common',
} satisfies ProfileCloudItem }
} }
function getSupabaseErrorCode(error: unknown) { function toProfile(profile: PublicProfile): Profile {
if (!error || typeof error !== 'object' || !('code' in error)) return null return {
const code = (error as { code?: unknown }).code ...profile,
return typeof code === 'string' ? code : null role: 'user',
} is_disabled: false,
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
} }
} }
export const useProfileStore = defineStore('profile-page', () => { export const useProfileStore = defineStore('profile-page', () => {
const authStore = useAuthStore()
const profilesById = ref<Record<string, Profile>>({}) const profilesById = ref<Record<string, Profile>>({})
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({}) const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
const loadingKeys = ref<Record<string, boolean>>({}) 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'}` const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
function getProfile(userId: string | null) { function getProfile(userId: string | null) {
if (!userId) return null return userId ? profilesById.value[userId] ?? null : null
return profilesById.value[userId] ?? null
} }
function getClouds(userId: string | null, isOwnProfile: boolean) { function getClouds(userId: string | null, isOwnProfile: boolean) {
if (!userId) return [] return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
return cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? []
} }
function getError(userId: string | null, isOwnProfile: boolean) { function getError(userId: string | null, isOwnProfile: boolean) {
if (!userId) return '' return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
return errorByKey.value[makeKey(userId, isOwnProfile)] ?? ''
} }
function isLoaded(userId: string | null, isOwnProfile: boolean) { function isLoaded(userId: string | null, isOwnProfile: boolean) {
if (!userId) return false return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value
return 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) { async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
@@ -100,34 +95,13 @@ export const useProfileStore = defineStore('profile-page', () => {
loadingKeys.value[key] = true loadingKeys.value[key] = true
errorByKey.value[key] = '' errorByKey.value[key] = ''
try { try {
const { data: profile, error: profileError } = await supabase const profilePromise = isOwnProfile && authStore.profile?.id === userId
.from('profiles') ? Promise.resolve(authStore.profile)
.select('*') : apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
.eq('id', userId) const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
.single() profilesById.value[userId] = profile
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
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)
} catch (error) { } catch (error) {
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败' errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
cloudsByKey.value[key] = [] cloudsByKey.value[key] = []
@@ -137,8 +111,7 @@ export const useProfileStore = defineStore('profile-page', () => {
} }
function isLoading(userId: string | null, isOwnProfile: boolean) { function isLoading(userId: string | null, isOwnProfile: boolean) {
if (!userId) return false return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
return !!loadingKeys.value[makeKey(userId, isOwnProfile)]
} }
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) { 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 key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key] const current = cloudsByKey.value[key]
if (!current) continue if (!current) continue
cloudsByKey.value[key] = current 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)) .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]) { for (const isOwnProfile of [true, false]) {
const key = makeKey(userId, isOwnProfile) const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key] const current = cloudsByKey.value[key]
if (!current) continue if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
} }
} }
@@ -178,80 +149,38 @@ export const useProfileStore = defineStore('profile-page', () => {
captured_at: string | null captured_at: string | null
is_hidden: boolean is_hidden: boolean
}) { }) {
const { data, error } = await supabase const updated = toProfileCloud(await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
.from('clouds') method: 'PATCH',
.update(patch) body: 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>)
patchCachedCloud(userId, cloudId, updated) patchCachedCloud(userId, cloudId, updated)
return updated return updated
} }
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) { async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
const { data, error } = await supabase await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
.from('clouds') method: 'PATCH',
.update({ is_hidden: isHidden }) body: { 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。')
}
patchCachedCloud(userId, cloudId, { is_hidden: isHidden }) patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
} }
async function deleteClouds(userId: string, cloudIds: string[]) { async function deleteClouds(userId: string, cloudIds: string[]) {
if (!cloudIds.length) return 0 if (!cloudIds.length) return 0
const { data, error } = await supabase if (cloudIds.length === 1) {
.from('clouds') await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' })
.delete() } else {
.eq('user_id', userId) for (let offset = 0; offset < cloudIds.length; offset += 100) {
.in('id', cloudIds) await apiRequest('/clouds/batch-delete', {
.select('id,image_url,thumbnail_url') method: 'POST',
body: { ids: cloudIds.slice(offset, offset + 100) },
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}`)
} }
} }
removeCachedClouds(userId, deletedIds) removeCachedClouds(userId, cloudIds)
return deletedIds.length return cloudIds.length
} }
return { 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' status: 'pending' | 'approved' | 'rejected'
is_hidden: boolean is_hidden: boolean
created_at: string created_at: string
cloud_types?: CloudType
profiles?: Profile
} }
export interface 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 { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue' import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue' import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase' import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth' 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' import type { CloudType, Profile } from '@/types/database'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images' type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
@@ -44,7 +44,6 @@ interface DashboardStats {
} }
const authStore = useAuthStore() const authStore = useAuthStore()
const profileStore = useProfileStore()
const message = useMessage() const message = useMessage()
const activeTab = ref<AdminTab>('dashboard') const activeTab = ref<AdminTab>('dashboard')
@@ -110,33 +109,25 @@ const cloudTypeStats = computed(() => {
const selectedReviewCount = computed(() => selectedReviewIds.value.size) const selectedReviewCount = computed(() => selectedReviewIds.value.size)
function readJoinedOne(value: unknown) { function toAdminCloud(row: ApiCloud): AdminCloud {
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)
return { return {
id: row.id as string, id: row.id,
user_id: row.user_id as string, user_id: row.user_id,
cloud_type_id: (row.cloud_type_id as number | null) ?? null, cloud_type_id: row.cloud_type_id,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null, custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url as string, image_url: row.image_url,
thumbnail_url: (row.thumbnail_url as string | null) ?? null, thumbnail_url: row.thumbnail_url,
latitude: (row.latitude as number | null) ?? null, latitude: row.latitude,
longitude: (row.longitude as number | null) ?? null, longitude: row.longitude,
location_name: (row.location_name as string | null) ?? null, location_name: row.location_name,
description: (row.description as string | null) ?? null, description: row.description,
captured_at: (row.captured_at as string | null) ?? null, captured_at: row.captured_at,
created_at: row.created_at as string, created_at: row.created_at,
status: row.status as CloudStatus, status: row.status,
is_hidden: (row.is_hidden as boolean) ?? false, is_hidden: row.is_hidden,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知云型', cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common', cloudTypeRarity: row.cloud_type_rarity || 'common',
username: (profile?.username as string) || '匿名用户', username: row.username || '匿名用户',
} }
} }
@@ -156,104 +147,30 @@ function formatCoordinate(value: number | null) {
} }
function getErrorMessage(error: unknown, fallback: string) { function getErrorMessage(error: unknown, fallback: string) {
if (!error || typeof error !== 'object') return fallback return error instanceof Error ? error.message : 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
} }
async function fetchStats() { async function fetchStats() {
const [ const stats = await apiRequest<AdminStats>('/admin/stats')
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 }),
])
dashboardStats.value = { dashboardStats.value = {
users: userCount, users: stats.users,
images: imageCount, images: stats.images,
todayUploads, todayUploads: stats.today_uploads,
pending: pendingCount, pending: stats.pending,
approved: approvedCount, approved: stats.approved,
rejected: rejectedCount, rejected: stats.rejected,
hidden: hiddenCount, hidden: stats.hidden,
} }
} }
async function fetchUsers() { async function fetchUsers() {
const { data, error } = await supabase const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
.from('profiles') users.value = result.items.map(item => item.profile)
.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[]
} }
async function fetchImages() { async function fetchImages() {
const { data, error } = await supabase const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
.from('clouds') images.value = result.items.map(toAdminCloud)
.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)
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id))) 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))) 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) { async function updateCloudStatus(ids: string[], status: CloudStatus) {
if (!ids.length) return if (!ids.length) return
@@ -334,18 +257,11 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
loadError.value = '' loadError.value = ''
try { try {
let query = supabase for (const chunk of chunkIds(ids)) {
.from('clouds') await apiRequest('/admin/clouds/status', {
.update({ status }) method: 'PATCH',
.select('id') body: { ids: chunk, status },
})
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。')
} }
patchImages(ids, { status }) patchImages(ids, { status })
@@ -369,15 +285,11 @@ async function updateImageVisibility(ids: string[], isHidden: boolean) {
loadError.value = '' loadError.value = ''
try { try {
const { data, error } = await supabase for (const chunk of chunkIds(ids)) {
.from('clouds') await apiRequest('/admin/clouds/visibility', {
.update({ is_hidden: isHidden }) method: 'PATCH',
.in('id', ids) body: { ids: chunk, is_hidden: isHidden },
.select('id') })
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
} }
patchImages(ids, { is_hidden: isHidden }) patchImages(ids, { is_hidden: isHidden })
@@ -412,16 +324,11 @@ async function deleteImages(clouds: AdminCloud[]) {
try { try {
const idsToDelete = clouds.map(cloud => cloud.id) const idsToDelete = clouds.map(cloud => cloud.id)
const cloudsByUser = new Map<string, string[]>() for (const chunk of chunkIds(idsToDelete)) {
await apiRequest('/admin/clouds/batch-delete', {
for (const cloud of clouds) { method: 'POST',
const current = cloudsByUser.get(cloud.user_id) || [] body: { ids: chunk },
current.push(cloud.id) })
cloudsByUser.set(cloud.user_id, current)
}
for (const [userId, cloudIds] of cloudsByUser.entries()) {
await profileStore.deleteClouds(userId, cloudIds)
} }
images.value = images.value.filter(item => !idsToDelete.includes(item.id)) images.value = images.value.filter(item => !idsToDelete.includes(item.id))
@@ -455,18 +362,11 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
loadError.value = '' loadError.value = ''
try { try {
const { data, error } = await supabase const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
.from('profiles') method: 'PATCH',
.update({ role }) body: { role },
.eq('id', user.id) })
.select('id,role') users.value = users.value.map(item => item.id === user.id ? result.profile : item)
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))
message.success('用户角色已更新') message.success('用户角色已更新')
} catch (error) { } catch (error) {
const text = getErrorMessage(error, '用户角色更新失败') const text = getErrorMessage(error, '用户角色更新失败')
@@ -488,18 +388,11 @@ async function toggleUserDisabled(user: Profile) {
try { try {
const nextDisabled = !user.is_disabled const nextDisabled = !user.is_disabled
const { data, error } = await supabase const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
.from('profiles') method: 'PATCH',
.update({ is_disabled: nextDisabled }) body: { is_disabled: nextDisabled },
.eq('id', user.id) })
.select('id,is_disabled') users.value = users.value.map(item => item.id === user.id ? result.profile : item)
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))
message.success(nextDisabled ? '用户已禁用' : '用户已恢复') message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) { } catch (error) {
const text = getErrorMessage(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> <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> <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"> <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都直接落到 Supabase。 集中处理社区云图审核、图片可见性、用户角色和运行数据。所有操作都由 FastAPI 服务统一处理。
</p> </p>
</div> </div>
+11 -10
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' 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 { 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 router = useRouter()
const state = ref<'loading' | 'success' | 'failed'>('loading') const state = ref<'loading' | 'success' | 'failed'>('loading')
@@ -22,18 +23,18 @@ function startCountdown() {
onMounted(async () => { onMounted(async () => {
try { try {
const confirmClient = createDetachedAuthClient() const token = typeof route.query.token === 'string' ? route.query.token : ''
const result = await resolveEmailAuthCallback({ if (!token) {
client: confirmClient,
allowedTypes: ['signup', 'email'],
})
if (!result.ok) {
state.value = 'failed' state.value = 'failed'
return 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' state.value = 'success'
startCountdown() startCountdown()
} catch { } catch {
+24 -38
View File
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' 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 { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
import { resolveEmailAuthCallback } from '@/lib/authEmail' import { apiRequest } from '@/lib/api'
import { supabase } from '@/lib/supabase'
const route = useRoute()
const router = useRouter() const router = useRouter()
const password = ref('') const password = ref('')
@@ -13,10 +13,11 @@ const error = ref('')
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking') const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
const loading = ref(false) const loading = ref(false)
const countdown = ref(5) const countdown = ref(5)
const resetToken = ref('')
let countdownTimer: ReturnType<typeof setInterval> | null = null let countdownTimer: ReturnType<typeof setInterval> | null = null
const canSubmit = computed(() => 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() { function showInvalidRecoveryLink() {
@@ -26,19 +27,12 @@ function showInvalidRecoveryLink() {
function getResetErrorMessage(value: unknown) { function getResetErrorMessage(value: unknown) {
const message = value instanceof Error ? value.message : '' const message = value instanceof Error ? value.message : ''
if ( if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
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')
) {
return '密码重置链接无效或已过期,请重新发送邮件。' return '密码重置链接无效或已过期,请重新发送邮件。'
} }
if (message.includes('Password should be at least')) { if (message.includes('8')) {
return '新密码至少需要 6 位。' return '新密码至少需要 8 位。'
} }
return message || '密码重置失败,请稍后重试。' return message || '密码重置失败,请稍后重试。'
@@ -49,31 +43,27 @@ function startCountdown() {
countdown.value-- countdown.value--
if (countdown.value <= 0) { if (countdown.value <= 0) {
if (countdownTimer) clearInterval(countdownTimer) if (countdownTimer) clearInterval(countdownTimer)
router.push('/') router.push('/login')
} }
}, 1000) }, 1000)
} }
async function initializeRecoverySession() { async function initializeRecoverySession() {
const result = await resolveEmailAuthCallback({ const token = typeof route.query.token === 'string' ? route.query.token : ''
client: supabase, if (!token) {
allowedTypes: ['recovery'], showInvalidRecoveryLink()
})
if (!result.ok || !result.session?.user) {
error.value = getResetErrorMessage(result.ok ? '链接无效或已过期。' : result.error)
state.value = 'invalid'
return return
} }
resetToken.value = token
state.value = 'ready' state.value = 'ready'
} }
async function handleResetPassword() { async function handleResetPassword() {
error.value = '' error.value = ''
if (password.value.length < 6) { if (password.value.length < 8) {
error.value = '新密码至少需要 6 位。' error.value = '新密码至少需要 8 位。'
return return
} }
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
@@ -81,17 +71,13 @@ async function handleResetPassword() {
return return
} }
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
if (sessionError || !session?.user) {
showInvalidRecoveryLink()
return
}
loading.value = true loading.value = true
try { try {
const { error: updateError } = await supabase.auth.updateUser({ password: password.value }) await apiRequest('/auth/reset-password', {
if (updateError) throw updateError method: 'POST',
auth: false,
body: { token: resetToken.value, password: password.value },
})
window.history.replaceState(null, '', window.location.pathname) window.history.replaceState(null, '', window.location.pathname)
countdown.value = 5 countdown.value = 5
state.value = 'success' state.value = 'success'
@@ -123,12 +109,12 @@ onUnmounted(() => {
v-if="state === 'success'" v-if="state === 'success'"
status="success" status="success"
title="密码已重置" title="密码已重置"
description="你已使用新密码完成更新,正在返回地图页。" description="你已使用新密码完成更新,现在可以重新登录。"
> >
<template #footer> <template #footer>
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转地图页面...</p> <p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/')">进入地图</NButton> <NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
</div> </div>
</template> </template>
</NResult> </NResult>
@@ -162,7 +148,7 @@ onUnmounted(() => {
required required
show-password-on="click" show-password-on="click"
autocomplete="new-password" autocomplete="new-password"
placeholder="至少 6 位" placeholder="至少 8 位"
/> />
</NFormItem> </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 { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue' import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue' import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase' import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia' import { useEncyclopediaStore } from '@/stores/encyclopedia'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database' import type { CloudType } from '@/types/database'
interface CloudGalleryItem { interface CloudGalleryItem {
@@ -82,49 +83,20 @@ function openGalleryDetail(item: CloudGalleryItem) {
} }
async function loadGallery(typeId: number) { async function loadGallery(typeId: number) {
const galleryQuery = supabase const result = await apiRequest<ApiPage<ApiCloud>>(`/cloud-types/${typeId}/clouds?page=1&page_size=24`, { auth: false })
.from('clouds') gallery.value = result.items.map(row => ({
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,profiles(username)') id: row.id,
.eq('cloud_type_id', typeId) image_url: row.image_url,
.eq('status', 'approved') thumbnail_url: row.thumbnail_url,
.eq('is_hidden', false) latitude: row.latitude,
.order('captured_at', { ascending: false, nullsFirst: false }) longitude: row.longitude,
.order('created_at', { ascending: false }) location_name: row.location_name,
.limit(24) description: row.description,
captured_at: row.captured_at,
const countQuery = supabase created_at: row.created_at,
.from('clouds') profiles: row.username ? { username: row.username } : null,
.select('*', { head: true, count: 'exact' }) }))
.eq('cloud_type_id', typeId) publicCount.value = result.total
.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
} }
async function loadPage() { 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 CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue' import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue' import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase' import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds' import { useCloudsStore } from '@/stores/clouds'
import { useProfileStore } from '@/stores/profile' import { useProfileStore } from '@/stores/profile'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database' import type { CloudType } from '@/types/database'
interface GalleryCloud { interface GalleryCloud {
@@ -87,135 +88,40 @@ function formatCoordinate(value: number | null) {
const normalizedSearch = computed(() => searchQuery.value.trim()) const normalizedSearch = computed(() => searchQuery.value.trim())
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@')) const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
function sanitizeSearchTerm(term: string) { function toGalleryCloud(row: ApiCloud) {
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
return { return {
id: row.id as string, id: row.id,
user_id: row.user_id as string, user_id: row.user_id,
cloud_type_id: (row.cloud_type_id as number | null) ?? null, cloud_type_id: row.cloud_type_id,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null, custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url as string, image_url: row.image_url,
thumbnail_url: (row.thumbnail_url as string | null) ?? null, thumbnail_url: row.thumbnail_url,
location_name: (row.location_name as string | null) ?? null, location_name: row.location_name,
description: (row.description as string | null) ?? null, description: row.description,
latitude: (row.latitude as number | null) ?? null, latitude: row.latitude,
longitude: (row.longitude as number | null) ?? null, longitude: row.longitude,
captured_at: (row.captured_at as string | null) ?? null, captured_at: row.captured_at,
created_at: row.created_at as string, created_at: row.created_at,
status: row.status as GalleryCloud['status'], status: row.status,
is_hidden: (row.is_hidden as boolean) ?? false, is_hidden: row.is_hidden,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知', cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common', cloudTypeRarity: row.cloud_type_rarity || 'common',
username: (profile?.username as string) || '匿名', username: row.username || '匿名',
} satisfies GalleryCloud } 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) { async function loadPage(page: number) {
loading.value = true loading.value = true
loadError.value = '' loadError.value = ''
try { try {
const filters = await resolveSearchFilters() const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
if (filters === null) { if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
galleryItems.value = [] if (normalizedSearch.value) params.set('search', normalizedSearch.value)
totalCount.value = 0
currentPage.value = 1
loading.value = false
return
}
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true }) const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
let dataQuery = buildFilteredQuery(FULL_SELECT) totalCount.value = result.total
.order('created_at', { ascending: false }) galleryItems.value = result.items.map(toGalleryCloud)
.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)
currentPage.value = page currentPage.value = page
} catch (error) { } catch (error) {
loadError.value = error instanceof Error ? error.message : '画廊加载失败' 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 ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue' import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue' import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
import { supabase } from '@/lib/supabase' import { apiRequest } from '@/lib/api'
import { loadAMap } from '@/lib/amap' import { loadAMap } from '@/lib/amap'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { ApiCloud } from '@/types/api'
import { NIcon } from 'naive-ui' import { NIcon } from 'naive-ui'
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler' import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
@@ -212,44 +213,36 @@ function hideHeader() {
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT)) window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
} }
function toCloudMarker(row: Record<string, unknown>): CloudMarkerData { function toCloudMarker(row: ApiCloud): CloudMarkerData {
const ct = row.cloud_types as Record<string, unknown> | null
const pf = row.profiles as Record<string, unknown> | null
return { return {
id: row.id as string, id: row.id,
latitude: row.latitude as number, latitude: row.latitude as number,
longitude: row.longitude as number, longitude: row.longitude as number,
imageUrl: row.image_url as string, imageUrl: row.image_url,
thumbnailUrl: (row.thumbnail_url as string | null) ?? null, thumbnailUrl: row.thumbnail_url,
locationName: (row.location_name as string | null) ?? null, locationName: row.location_name,
description: (row.description as string | null) ?? null, description: row.description,
cloudTypeName: (ct?.name as string) || (row.custom_cloud_type as string) || '未知', cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common', rarity: row.cloud_type_rarity || 'common',
username: (pf?.username as string) || '匿名', username: row.username || '匿名',
capturedAt: (row.captured_at as string) || (row.created_at as string), capturedAt: row.captured_at || row.created_at,
createdAt: row.created_at as string, createdAt: row.created_at,
} }
} }
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> { async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
const { data, error } = await supabase const params = new URLSearchParams({
.from('clouds') start: start.toISOString(),
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)') end: end.toISOString(),
.eq('status', 'approved') time_field: field,
.eq('is_hidden', false) })
.not('latitude', 'is', null) try {
.not('longitude', 'is', null) const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
.gte(field, start.toISOString()) return rows.map(toCloudMarker)
.lt(field, end.toISOString()) } catch (error) {
.order(field, { ascending: true }) statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
.limit(1000)
if (error) {
statusText.value = `查询失败: ${error.message}`
return [] return []
} }
return ((data || []) as Array<Record<string, unknown>>).map(toCloudMarker)
} }
async function loadRealtimeClouds() { async function loadRealtimeClouds() {
+3 -3
View File
@@ -54,8 +54,8 @@ async function savePassword() {
error.value = '' error.value = ''
success.value = '' success.value = ''
if (newPassword.value.length < 6) { if (newPassword.value.length < 8) {
error.value = '新密码至少需要 6 位。' error.value = '新密码至少需要 8 位。'
return return
} }
if (newPassword.value !== confirmNewPassword.value) { if (newPassword.value !== confirmNewPassword.value) {
@@ -145,7 +145,7 @@ async function savePassword() {
<div> <div>
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2> <h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
<p class="mt-2 text-sm leading-6 text-slate-500"> <p class="mt-2 text-sm leading-6 text-slate-500">
修改后下次登录需要使用新密码。建议使用至少 6 位且不易猜测的密码。 修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
</p> </p>
</div> </div>
<div class="grid gap-3"> <div class="grid gap-3">