Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6359231b99 |
@@ -24,3 +24,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Lock
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -1,5 +1,53 @@
|
|||||||
# 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 + Vite frontend with a Node/Express API, PostgreSQL, and local file storage.
|
||||||
|
|
||||||
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).
|
## Development
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create the PostgreSQL schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f server/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="postgres://..." JWT_SECRET="change-me" npm run dev:api
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start the frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server proxies `/api/*` and `/uploads/*` to `http://localhost:3001`.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `npm run dev` - Vite frontend dev server
|
||||||
|
- `npm run dev:api` - Express API with `tsx`
|
||||||
|
- `npm run build` - Vue typecheck and production frontend build
|
||||||
|
- `npm run typecheck:api` - Typecheck the Express API
|
||||||
|
- `npm run start:api` - Start the API with Node and `tsx`
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- `VITE_AMAP_KEY` - required for map features
|
||||||
|
- `VITE_SITE_URL` - optional canonical URL for generated SEO files
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
|
- `JWT_SECRET` - secret for HTTP-only JWT session cookies
|
||||||
|
- `PORT` - API port, defaults to `3001`
|
||||||
|
- `UPLOAD_ROOT` - optional upload directory, defaults to `server/uploads`
|
||||||
|
|||||||
Generated
+1929
-91
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -5,27 +5,41 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:api": "tsx server/index.ts",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"typecheck:api": "tsc --noEmit --ignoreConfig --target es2023 --module nodenext --moduleResolution nodenext --esModuleInterop --skipLibCheck server/index.ts",
|
||||||
|
"start:api": "node --import tsx server/index.ts",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.44.1",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"@vicons/tabler": "^0.13.0",
|
"@vicons/tabler": "^0.13.0",
|
||||||
"@vicons/utils": "^0.1.4",
|
"@vicons/utils": "^0.1.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vue-tsc": "^3.2.8"
|
"vue-tsc": "^3.2.8"
|
||||||
|
|||||||
+790
@@ -0,0 +1,790 @@
|
|||||||
|
import express, { type NextFunction, type Request, type Response } from 'express'
|
||||||
|
import cookieParser from 'cookie-parser'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import multer from 'multer'
|
||||||
|
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
type Role = 'user' | 'admin'
|
||||||
|
type CloudStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
|
||||||
|
interface AuthUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
role: Role
|
||||||
|
is_disabled: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
sub: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: AuthUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Pool } = pg
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const rootDir = path.resolve(__dirname, '..')
|
||||||
|
const uploadRoot = process.env.UPLOAD_ROOT
|
||||||
|
? path.resolve(process.env.UPLOAD_ROOT)
|
||||||
|
: path.join(rootDir, 'server/uploads')
|
||||||
|
const cloudUploadRoot = path.join(uploadRoot, 'clouds')
|
||||||
|
const cookieName = 'opencloud_session'
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || 'opencloud-dev-secret-change-me'
|
||||||
|
const port = Number(process.env.PORT || 3001)
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
console.warn('DATABASE_URL is not set. API routes will fail until a PostgreSQL connection string is provided.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
files: 2,
|
||||||
|
fileSize: 20 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '1mb' }))
|
||||||
|
app.use(cookieParser())
|
||||||
|
app.use('/uploads', express.static(uploadRoot))
|
||||||
|
|
||||||
|
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
void fn(req, res, next).catch(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDatabaseUrl() {
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
const error = new Error('缺少 DATABASE_URL,后端无法连接 PostgreSQL。')
|
||||||
|
Object.assign(error, { status: 500 })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signSession(userId: string) {
|
||||||
|
return jwt.sign({ sub: userId } satisfies JwtPayload, jwtSecret, { expiresIn: '7d' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCookie(res: Response, userId: string) {
|
||||||
|
res.cookie(cookieName, signSession(userId), {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionCookie(res: Response) {
|
||||||
|
res.clearCookie(cookieName, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicUser(row: Record<string, unknown>): AuthUser {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
email: String(row.email),
|
||||||
|
username: String(row.username),
|
||||||
|
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||||
|
role: row.role as Role,
|
||||||
|
is_disabled: Boolean(row.is_disabled),
|
||||||
|
created_at: String(row.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicProfile(row: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
username: String(row.username),
|
||||||
|
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||||
|
role: row.role as Role,
|
||||||
|
is_disabled: Boolean(row.is_disabled),
|
||||||
|
created_at: String(row.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserById(id: string) {
|
||||||
|
assertDatabaseUrl()
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'select id,email,username,avatar_url,role,is_disabled,created_at from users where id = $1',
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
return rows[0] ? publicUser(rows[0]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optionalAuth(req: Request, _res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const token = req.cookies?.[cookieName]
|
||||||
|
if (!token) return next()
|
||||||
|
|
||||||
|
const payload = jwt.verify(token, jwtSecret) as JwtPayload
|
||||||
|
const user = await fetchUserById(payload.sub)
|
||||||
|
if (user && !user.is_disabled) req.user = user
|
||||||
|
return next()
|
||||||
|
} catch {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(req: Request, _res: Response, next: NextFunction) {
|
||||||
|
if (!req.user) {
|
||||||
|
const error = new Error('请先登录。')
|
||||||
|
Object.assign(error, { status: 401 })
|
||||||
|
return next(error)
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(req: Request, _res: Response, next: NextFunction) {
|
||||||
|
if (req.user?.role !== 'admin') {
|
||||||
|
const error = new Error('需要管理员权限。')
|
||||||
|
Object.assign(error, { status: 403 })
|
||||||
|
return next(error)
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(optionalAuth)
|
||||||
|
|
||||||
|
function toCloudRow(row: Record<string, unknown>) {
|
||||||
|
const cloudTypeId = row.cloud_type_id as number | null
|
||||||
|
const username = row.username as string | null
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
user_id: String(row.user_id),
|
||||||
|
cloud_type_id: cloudTypeId,
|
||||||
|
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
|
||||||
|
image_url: String(row.image_url),
|
||||||
|
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||||
|
latitude: row.latitude === null ? null : Number(row.latitude),
|
||||||
|
longitude: row.longitude === null ? null : Number(row.longitude),
|
||||||
|
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,
|
||||||
|
status: row.status as CloudStatus,
|
||||||
|
is_hidden: Boolean(row.is_hidden),
|
||||||
|
created_at: String(row.created_at),
|
||||||
|
cloud_types: cloudTypeId
|
||||||
|
? {
|
||||||
|
id: cloudTypeId,
|
||||||
|
name: String(row.cloud_type_name),
|
||||||
|
name_en: String(row.cloud_type_name_en),
|
||||||
|
genus: String(row.cloud_type_genus),
|
||||||
|
rarity: row.cloud_type_rarity,
|
||||||
|
description: (row.cloud_type_description as string | null) ?? null,
|
||||||
|
icon_url: (row.cloud_type_icon_url as string | null) ?? null,
|
||||||
|
created_at: String(row.cloud_type_created_at),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
profiles: username
|
||||||
|
? {
|
||||||
|
id: String(row.user_id),
|
||||||
|
username,
|
||||||
|
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||||
|
role: row.user_role as Role,
|
||||||
|
is_disabled: Boolean(row.user_is_disabled),
|
||||||
|
created_at: String(row.user_created_at),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudSelect = `
|
||||||
|
c.*,
|
||||||
|
ct.name as cloud_type_name,
|
||||||
|
ct.name_en as cloud_type_name_en,
|
||||||
|
ct.genus as cloud_type_genus,
|
||||||
|
ct.rarity as cloud_type_rarity,
|
||||||
|
ct.description as cloud_type_description,
|
||||||
|
ct.icon_url as cloud_type_icon_url,
|
||||||
|
ct.created_at as cloud_type_created_at,
|
||||||
|
u.username,
|
||||||
|
u.avatar_url,
|
||||||
|
u.role as user_role,
|
||||||
|
u.is_disabled as user_is_disabled,
|
||||||
|
u.created_at as user_created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
const cloudJoin = `
|
||||||
|
from clouds c
|
||||||
|
left join cloud_types ct on ct.id = c.cloud_type_id
|
||||||
|
join users u on u.id = c.user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
function parseString(value: unknown) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNullableNumber(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') return null
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNullableInteger(value: unknown) {
|
||||||
|
const number = parseNullableNumber(value)
|
||||||
|
return number === null ? null : Math.trunc(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoolean(value: unknown) {
|
||||||
|
return value === true || value === 'true' || value === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUploadExtension(file: Express.Multer.File) {
|
||||||
|
const extension = path.extname(file.originalname).toLowerCase().replace(/[^a-z0-9.]/g, '')
|
||||||
|
if (extension && ['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) return extension
|
||||||
|
if (file.mimetype === 'image/png') return '.png'
|
||||||
|
if (file.mimetype === 'image/webp') return '.webp'
|
||||||
|
if (file.mimetype === 'image/gif') return '.gif'
|
||||||
|
return '.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCloudFile(userId: string, file: Express.Multer.File, suffix = '') {
|
||||||
|
const userDir = path.join(cloudUploadRoot, userId)
|
||||||
|
await mkdir(userDir, { recursive: true })
|
||||||
|
const random = Math.random().toString(36).slice(2, 10)
|
||||||
|
const extension = suffix ? '.jpg' : normalizeUploadExtension(file)
|
||||||
|
const filename = `${Date.now()}-${random}${suffix}${extension}`
|
||||||
|
const absolutePath = path.join(userDir, filename)
|
||||||
|
await writeFile(absolutePath, file.buffer)
|
||||||
|
return {
|
||||||
|
absolutePath,
|
||||||
|
publicPath: `/uploads/clouds/${userId}/${filename}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePublicFile(publicPath: string | null) {
|
||||||
|
if (!publicPath || !publicPath.startsWith('/uploads/clouds/')) return
|
||||||
|
const absolutePath = path.resolve(uploadRoot, publicPath.replace(/^\/uploads\//, ''))
|
||||||
|
if (!absolutePath.startsWith(cloudUploadRoot)) return
|
||||||
|
await rm(absolutePath, { force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/auth/register', asyncHandler(async (req, res) => {
|
||||||
|
assertDatabaseUrl()
|
||||||
|
const email = parseString(req.body.email)?.toLowerCase()
|
||||||
|
const password = typeof req.body.password === 'string' ? req.body.password : ''
|
||||||
|
const username = parseString(req.body.username)
|
||||||
|
|
||||||
|
if (!email || !username || password.length < 8) {
|
||||||
|
res.status(400).json({ message: '请填写有效邮箱、用户名和至少 8 位密码。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`insert into users (email, password_hash, username)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
returning id,email,username,avatar_url,role,is_disabled,created_at`,
|
||||||
|
[email, passwordHash, username],
|
||||||
|
)
|
||||||
|
const user = publicUser(rows[0])
|
||||||
|
setSessionCookie(res, user.id)
|
||||||
|
res.status(201).json({ user, profile: publicProfile(rows[0]) })
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
|
||||||
|
res.status(409).json({ message: '邮箱或昵称已被使用。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.post('/api/auth/login', asyncHandler(async (req, res) => {
|
||||||
|
assertDatabaseUrl()
|
||||||
|
const email = parseString(req.body.email)?.toLowerCase()
|
||||||
|
const password = typeof req.body.password === 'string' ? req.body.password : ''
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).json({ message: '请输入邮箱和密码。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'select id,email,password_hash,username,avatar_url,role,is_disabled,created_at from users where email = $1',
|
||||||
|
[email],
|
||||||
|
)
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row || !(await bcrypt.compare(password, row.password_hash))) {
|
||||||
|
res.status(401).json({ message: '邮箱或密码错误。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (row.is_disabled) {
|
||||||
|
res.status(403).json({ message: '账号已被禁用。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = publicUser(row)
|
||||||
|
setSessionCookie(res, user.id)
|
||||||
|
res.json({ user, profile: publicProfile(row) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.post('/api/auth/logout', (_req, res) => {
|
||||||
|
clearSessionCookie(res)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/auth/me', (req, res) => {
|
||||||
|
res.json({ user: req.user ?? null, profile: req.user ? publicProfile(req.user as unknown as Record<string, unknown>) : null })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/auth/password', requireAuth, asyncHandler(async (req, res) => {
|
||||||
|
const password = typeof req.body.password === 'string' ? req.body.password : ''
|
||||||
|
if (password.length < 6) {
|
||||||
|
res.status(400).json({ message: '新密码至少需要 6 位。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
|
await pool.query('update users set password_hash = $1, updated_at = now() where id = $2', [passwordHash, req.user!.id])
|
||||||
|
res.json({ ok: true })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/profiles/check-username', asyncHandler(async (req, res) => {
|
||||||
|
const username = parseString(req.query.username)
|
||||||
|
const exclude = parseString(req.query.exclude)
|
||||||
|
if (!username) {
|
||||||
|
res.json({ available: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params: unknown[] = [username]
|
||||||
|
let sql = 'select id from users where username = $1'
|
||||||
|
if (exclude) {
|
||||||
|
params.push(exclude)
|
||||||
|
sql += ' and id <> $2'
|
||||||
|
}
|
||||||
|
sql += ' limit 1'
|
||||||
|
const { rows } = await pool.query(sql, params)
|
||||||
|
res.json({ available: rows.length === 0 })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.patch('/api/me/profile', requireAuth, asyncHandler(async (req, res) => {
|
||||||
|
const username = parseString(req.body.username)
|
||||||
|
if (!username || username.length < 2 || username.length > 32) {
|
||||||
|
res.status(400).json({ message: '昵称长度需要在 2 到 32 个字符之间。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`update users set username = $1, updated_at = now()
|
||||||
|
where id = $2
|
||||||
|
returning id,email,username,avatar_url,role,is_disabled,created_at`,
|
||||||
|
[username, req.user!.id],
|
||||||
|
)
|
||||||
|
res.json({ profile: publicProfile(rows[0]), user: publicUser(rows[0]) })
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
|
||||||
|
res.status(409).json({ message: '这个昵称已经被使用,请换一个。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/cloud-types', asyncHandler(async (_req, res) => {
|
||||||
|
const { rows } = await pool.query('select * from cloud_types order by id')
|
||||||
|
res.json({ data: rows })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/me/collections', requireAuth, asyncHandler(async (req, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`select uc.*, c.image_url, c.thumbnail_url, c.captured_at, c.created_at as cloud_created_at, c.location_name
|
||||||
|
from user_collections uc
|
||||||
|
left join clouds c on c.id = uc.first_cloud_id
|
||||||
|
where uc.user_id = $1
|
||||||
|
order by uc.unlocked_at asc`,
|
||||||
|
[req.user!.id],
|
||||||
|
)
|
||||||
|
res.json({
|
||||||
|
data: rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
user_id: row.user_id,
|
||||||
|
cloud_type_id: row.cloud_type_id,
|
||||||
|
first_cloud_id: row.first_cloud_id,
|
||||||
|
unlocked_at: row.unlocked_at,
|
||||||
|
firstCloud: row.first_cloud_id
|
||||||
|
? {
|
||||||
|
id: row.first_cloud_id,
|
||||||
|
image_url: row.image_url,
|
||||||
|
thumbnail_url: row.thumbnail_url,
|
||||||
|
captured_at: row.captured_at,
|
||||||
|
created_at: row.cloud_created_at,
|
||||||
|
location_name: row.location_name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/profiles/:id', asyncHandler(async (req, res) => {
|
||||||
|
const profileId = String(req.params.id)
|
||||||
|
const user = await fetchUserById(profileId)
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ message: '用户不存在。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ profile: publicProfile(user as unknown as Record<string, unknown>) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/profiles/:id/clouds', asyncHandler(async (req, res) => {
|
||||||
|
const profileId = String(req.params.id)
|
||||||
|
const own = req.user?.id === profileId && req.query.own === 'true'
|
||||||
|
const params: unknown[] = [profileId]
|
||||||
|
let sql = `select ${cloudSelect} ${cloudJoin} where c.user_id = $1`
|
||||||
|
if (!own) sql += ` and c.status = 'approved' and c.is_hidden = false`
|
||||||
|
sql += ' order by c.captured_at desc nulls last, c.created_at desc'
|
||||||
|
const { rows } = await pool.query(sql, params)
|
||||||
|
res.json({ data: rows.map(toCloudRow) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/clouds', asyncHandler(async (req, res) => {
|
||||||
|
const page = Math.max(1, Number(req.query.page || 1))
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize || 50)))
|
||||||
|
const params: unknown[] = []
|
||||||
|
const where = [`c.status = 'approved'`, 'c.is_hidden = false']
|
||||||
|
|
||||||
|
const typeId = req.query.typeId === 'all' ? null : parseNullableInteger(req.query.typeId)
|
||||||
|
if (typeId) {
|
||||||
|
params.push(typeId)
|
||||||
|
where.push(`c.cloud_type_id = $${params.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = parseString(req.query.search)
|
||||||
|
if (search) {
|
||||||
|
if (search.startsWith('@')) {
|
||||||
|
params.push(`%${search.slice(1).trim()}%`)
|
||||||
|
where.push(`u.username ilike $${params.length}`)
|
||||||
|
} else {
|
||||||
|
params.push(`%${search}%`)
|
||||||
|
where.push(`(ct.name ilike $${params.length} or ct.name_en ilike $${params.length} or c.custom_cloud_type ilike $${params.length})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = where.length ? `where ${where.join(' and ')}` : ''
|
||||||
|
const countResult = await pool.query(`select count(*)::int as count ${cloudJoin} ${whereSql}`, params)
|
||||||
|
params.push(pageSize, (page - 1) * pageSize)
|
||||||
|
const dataResult = await pool.query(
|
||||||
|
`select ${cloudSelect} ${cloudJoin} ${whereSql}
|
||||||
|
order by c.created_at desc
|
||||||
|
limit $${params.length - 1} offset $${params.length}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
res.json({ data: dataResult.rows.map(toCloudRow), count: countResult.rows[0].count })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/clouds/map', asyncHandler(async (req, res) => {
|
||||||
|
const field = req.query.field === 'created_at' ? 'created_at' : 'captured_at'
|
||||||
|
const start = parseString(req.query.start)
|
||||||
|
const end = parseString(req.query.end)
|
||||||
|
if (!start || !end) {
|
||||||
|
res.status(400).json({ message: '缺少时间范围。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`select ${cloudSelect} ${cloudJoin}
|
||||||
|
where c.status = 'approved'
|
||||||
|
and c.is_hidden = false
|
||||||
|
and c.latitude is not null
|
||||||
|
and c.longitude is not null
|
||||||
|
and c.${field} >= $1
|
||||||
|
and c.${field} < $2
|
||||||
|
order by c.${field} asc
|
||||||
|
limit 1000`,
|
||||||
|
[start, end],
|
||||||
|
)
|
||||||
|
res.json({ data: rows.map(toCloudRow) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/cloud-types/:id/gallery', asyncHandler(async (req, res) => {
|
||||||
|
const typeId = Number(String(req.params.id))
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`select count(*)::int as count from clouds
|
||||||
|
where cloud_type_id = $1 and status = 'approved' and is_hidden = false`,
|
||||||
|
[typeId],
|
||||||
|
)
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`select ${cloudSelect} ${cloudJoin}
|
||||||
|
where c.cloud_type_id = $1 and c.status = 'approved' and c.is_hidden = false
|
||||||
|
order by c.captured_at desc nulls last, c.created_at desc
|
||||||
|
limit 24`,
|
||||||
|
[typeId],
|
||||||
|
)
|
||||||
|
res.json({ data: rows.map(toCloudRow), count: countResult.rows[0].count })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.post('/api/clouds', requireAuth, upload.fields([
|
||||||
|
{ name: 'image', maxCount: 1 },
|
||||||
|
{ name: 'thumbnail', maxCount: 1 },
|
||||||
|
]), asyncHandler(async (req, res) => {
|
||||||
|
const files = req.files as Record<string, Express.Multer.File[]> | undefined
|
||||||
|
const image = files?.image?.[0]
|
||||||
|
const thumbnail = files?.thumbnail?.[0]
|
||||||
|
if (!image || !thumbnail) {
|
||||||
|
res.status(400).json({ message: '请同时上传原图和缩略图。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageFile: Awaited<ReturnType<typeof writeCloudFile>> | null = null
|
||||||
|
let thumbnailFile: Awaited<ReturnType<typeof writeCloudFile>> | null = null
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
imageFile = await writeCloudFile(req.user!.id, image)
|
||||||
|
thumbnailFile = await writeCloudFile(req.user!.id, thumbnail, '-thumb')
|
||||||
|
await client.query('begin')
|
||||||
|
const insertResult = await client.query(
|
||||||
|
`insert into clouds (
|
||||||
|
user_id, cloud_type_id, custom_cloud_type, image_url, thumbnail_url,
|
||||||
|
latitude, longitude, location_name, description, captured_at, status, is_hidden
|
||||||
|
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11)
|
||||||
|
returning id, cloud_type_id, created_at`,
|
||||||
|
[
|
||||||
|
req.user!.id,
|
||||||
|
parseNullableInteger(req.body.cloud_type_id),
|
||||||
|
parseString(req.body.custom_cloud_type),
|
||||||
|
imageFile.publicPath,
|
||||||
|
thumbnailFile.publicPath,
|
||||||
|
parseNullableNumber(req.body.latitude),
|
||||||
|
parseNullableNumber(req.body.longitude),
|
||||||
|
parseString(req.body.location_name),
|
||||||
|
parseString(req.body.description),
|
||||||
|
parseString(req.body.captured_at),
|
||||||
|
parseBoolean(req.body.is_hidden),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const inserted = insertResult.rows[0]
|
||||||
|
let unlockedBadge = null
|
||||||
|
if (inserted.cloud_type_id) {
|
||||||
|
const collectionResult = await client.query(
|
||||||
|
`insert into user_collections (user_id, cloud_type_id, first_cloud_id)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
on conflict (user_id, cloud_type_id) do nothing
|
||||||
|
returning cloud_type_id, unlocked_at`,
|
||||||
|
[req.user!.id, inserted.cloud_type_id, inserted.id],
|
||||||
|
)
|
||||||
|
const collection = collectionResult.rows[0]
|
||||||
|
if (collection) {
|
||||||
|
const typeResult = await client.query(
|
||||||
|
'select id,name,name_en,rarity from cloud_types where id = $1',
|
||||||
|
[collection.cloud_type_id],
|
||||||
|
)
|
||||||
|
const type = typeResult.rows[0]
|
||||||
|
if (type) {
|
||||||
|
unlockedBadge = {
|
||||||
|
cloudTypeId: type.id,
|
||||||
|
cloudName: type.name,
|
||||||
|
cloudNameEn: type.name_en,
|
||||||
|
rarity: type.rarity,
|
||||||
|
unlockedAt: collection.unlocked_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.query('commit')
|
||||||
|
res.status(201).json({ cloud: inserted, unlockedBadge })
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback').catch(() => undefined)
|
||||||
|
await Promise.all([
|
||||||
|
removePublicFile(imageFile?.publicPath ?? null),
|
||||||
|
removePublicFile(thumbnailFile?.publicPath ?? null),
|
||||||
|
])
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.patch('/api/clouds/:id', requireAuth, asyncHandler(async (req, res) => {
|
||||||
|
const cloudId = String(req.params.id)
|
||||||
|
const isAdmin = req.user!.role === 'admin'
|
||||||
|
const ownerResult = await pool.query('select user_id from clouds where id = $1', [cloudId])
|
||||||
|
const owner = ownerResult.rows[0]?.user_id
|
||||||
|
if (!owner) {
|
||||||
|
res.status(404).json({ message: '图片不存在。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isAdmin && owner !== req.user!.id) {
|
||||||
|
res.status(403).json({ message: '只能修改自己的图片。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = ['cloud_type_id', 'custom_cloud_type', 'latitude', 'longitude', 'location_name', 'description', 'captured_at', 'is_hidden', 'status']
|
||||||
|
const set: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (!(key in req.body)) continue
|
||||||
|
if (key === 'status' && !isAdmin) continue
|
||||||
|
params.push(key === 'is_hidden'
|
||||||
|
? parseBoolean(req.body[key])
|
||||||
|
: key === 'cloud_type_id'
|
||||||
|
? parseNullableInteger(req.body[key])
|
||||||
|
: key === 'latitude' || key === 'longitude'
|
||||||
|
? parseNullableNumber(req.body[key])
|
||||||
|
: parseString(req.body[key]))
|
||||||
|
set.push(`${key} = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (!set.length) {
|
||||||
|
res.status(400).json({ message: '没有可更新的字段。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.push(cloudId)
|
||||||
|
await pool.query(`update clouds set ${set.join(', ')}, updated_at = now() where id = $${params.length}`, params)
|
||||||
|
const { rows } = await pool.query(`select ${cloudSelect} ${cloudJoin} where c.id = $1`, [cloudId])
|
||||||
|
res.json({ cloud: toCloudRow(rows[0]) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.delete('/api/clouds/:id', requireAuth, asyncHandler(async (req, res) => {
|
||||||
|
const cloudId = String(req.params.id)
|
||||||
|
const isAdmin = req.user!.role === 'admin'
|
||||||
|
const { rows } = await pool.query('select id,user_id,image_url,thumbnail_url from clouds where id = $1', [cloudId])
|
||||||
|
const cloud = rows[0]
|
||||||
|
if (!cloud) {
|
||||||
|
res.status(404).json({ message: '图片不存在。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isAdmin && cloud.user_id !== req.user!.id) {
|
||||||
|
res.status(403).json({ message: '只能删除自己的图片。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await pool.query('delete from clouds where id = $1', [cloudId])
|
||||||
|
const fileErrors = await Promise.allSettled([
|
||||||
|
removePublicFile(cloud.image_url),
|
||||||
|
removePublicFile(cloud.thumbnail_url),
|
||||||
|
])
|
||||||
|
const failed = fileErrors.some(result => result.status === 'rejected')
|
||||||
|
if (failed) console.error('cloud file deletion failed', fileErrors)
|
||||||
|
res.json({ deleted: [cloud.id], fileCleanupFailed: failed })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/admin/stats', requireAuth, requireAdmin, asyncHandler(async (_req, res) => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`select
|
||||||
|
(select count(*)::int from users) as users,
|
||||||
|
(select count(*)::int from clouds) as images,
|
||||||
|
(select count(*)::int from clouds where created_at >= $1) as "todayUploads",
|
||||||
|
(select count(*)::int from clouds where status = 'pending') as pending,
|
||||||
|
(select count(*)::int from clouds where status = 'approved') as approved,
|
||||||
|
(select count(*)::int from clouds where status = 'rejected') as rejected,
|
||||||
|
(select count(*)::int from clouds where is_hidden = true) as hidden`,
|
||||||
|
[today.toISOString()],
|
||||||
|
)
|
||||||
|
res.json({ stats: rows[0] })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/admin/users', requireAuth, requireAdmin, asyncHandler(async (_req, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'select id,username,avatar_url,role,is_disabled,created_at from users order by created_at desc limit 100',
|
||||||
|
)
|
||||||
|
res.json({ data: rows.map(publicProfile) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.patch('/api/admin/users/:id', requireAuth, requireAdmin, asyncHandler(async (req, res) => {
|
||||||
|
const userId = String(req.params.id)
|
||||||
|
if (userId === req.user!.id && (req.body.role !== 'admin' || parseBoolean(req.body.is_disabled))) {
|
||||||
|
res.status(400).json({ message: '不能移除自己的管理员权限或禁用当前账号。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const set: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
if (req.body.role === 'user' || req.body.role === 'admin') {
|
||||||
|
params.push(req.body.role)
|
||||||
|
set.push(`role = $${params.length}`)
|
||||||
|
}
|
||||||
|
if ('is_disabled' in req.body) {
|
||||||
|
params.push(parseBoolean(req.body.is_disabled))
|
||||||
|
set.push(`is_disabled = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (!set.length) {
|
||||||
|
res.status(400).json({ message: '没有可更新的字段。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.push(userId)
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`update users set ${set.join(', ')}, updated_at = now()
|
||||||
|
where id = $${params.length}
|
||||||
|
returning id,username,avatar_url,role,is_disabled,created_at`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
res.json({ profile: publicProfile(rows[0]) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/api/admin/clouds', requireAuth, requireAdmin, asyncHandler(async (_req, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`select ${cloudSelect} ${cloudJoin}
|
||||||
|
order by c.created_at desc
|
||||||
|
limit 120`,
|
||||||
|
)
|
||||||
|
res.json({ data: rows.map(toCloudRow) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.patch('/api/admin/clouds', requireAuth, requireAdmin, asyncHandler(async (req, res) => {
|
||||||
|
const ids = Array.isArray(req.body.ids) ? req.body.ids.filter((id: unknown) => typeof id === 'string') : []
|
||||||
|
if (!ids.length) {
|
||||||
|
res.status(400).json({ message: '请选择图片。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const set: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
if (['pending', 'approved', 'rejected'].includes(req.body.status)) {
|
||||||
|
params.push(req.body.status)
|
||||||
|
set.push(`status = $${params.length}`)
|
||||||
|
}
|
||||||
|
if ('is_hidden' in req.body) {
|
||||||
|
params.push(parseBoolean(req.body.is_hidden))
|
||||||
|
set.push(`is_hidden = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (!set.length) {
|
||||||
|
res.status(400).json({ message: '没有可更新的字段。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.push(ids)
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`update clouds set ${set.join(', ')}, updated_at = now()
|
||||||
|
where id = any($${params.length}::uuid[])
|
||||||
|
returning id`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
res.json({ updated: rows.map(row => row.id) })
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
const payload = error as { status?: number; message?: string }
|
||||||
|
const status = payload.status || 500
|
||||||
|
if (status >= 500) console.error(error)
|
||||||
|
res.status(status).json({ message: payload.message || '服务器内部错误。' })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existsSync(uploadRoot)) {
|
||||||
|
await mkdir(uploadRoot, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`OpenCloud API listening on http://localhost:${port}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type user_role as enum ('user', 'admin');
|
||||||
|
exception
|
||||||
|
when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type cloud_status as enum ('pending', 'approved', 'rejected');
|
||||||
|
exception
|
||||||
|
when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type cloud_rarity as enum ('common', 'uncommon', 'rare');
|
||||||
|
exception
|
||||||
|
when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create table if not exists users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
email text not null unique,
|
||||||
|
password_hash text not null,
|
||||||
|
username text not null unique,
|
||||||
|
avatar_url text,
|
||||||
|
role user_role not null default 'user',
|
||||||
|
is_disabled boolean not null default false,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists cloud_types (
|
||||||
|
id integer primary key,
|
||||||
|
name text not null,
|
||||||
|
name_en text not null,
|
||||||
|
genus text not null,
|
||||||
|
rarity cloud_rarity not null default 'common',
|
||||||
|
description text,
|
||||||
|
icon_url text,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists clouds (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
cloud_type_id integer references cloud_types(id) on delete set null,
|
||||||
|
custom_cloud_type text,
|
||||||
|
image_url text not null,
|
||||||
|
thumbnail_url text,
|
||||||
|
latitude numeric(9, 6),
|
||||||
|
longitude numeric(9, 6),
|
||||||
|
location_name text,
|
||||||
|
description text,
|
||||||
|
captured_at timestamptz,
|
||||||
|
status cloud_status not null default 'pending',
|
||||||
|
is_hidden boolean not null default false,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists user_collections (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
cloud_type_id integer not null references cloud_types(id) on delete cascade,
|
||||||
|
first_cloud_id uuid references clouds(id) on delete set null,
|
||||||
|
unlocked_at timestamptz not null default now(),
|
||||||
|
unique (user_id, cloud_type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_clouds_public_gallery
|
||||||
|
on clouds (status, is_hidden, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_clouds_type_public
|
||||||
|
on clouds (cloud_type_id, status, is_hidden, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_clouds_map_captured
|
||||||
|
on clouds (captured_at, status, is_hidden)
|
||||||
|
where latitude is not null and longitude is not null;
|
||||||
|
|
||||||
|
create index if not exists idx_clouds_user_created
|
||||||
|
on clouds (user_id, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_user_collections_user
|
||||||
|
on user_collections (user_id, unlocked_at);
|
||||||
|
|
||||||
|
insert into cloud_types (id, name, name_en, genus, rarity, description)
|
||||||
|
values
|
||||||
|
(1, '积云', 'Cumulus', '低云', 'common', '轮廓清晰、底部较平的棉花状云,常见于晴朗天气。'),
|
||||||
|
(2, '层云', 'Stratus', '低云', 'common', '低而均匀的灰白色云层,常覆盖天空并带来阴沉观感。'),
|
||||||
|
(3, '卷云', 'Cirrus', '高云', 'common', '纤细如羽毛的高云,由冰晶组成,常预示天气变化。'),
|
||||||
|
(4, '积雨云', 'Cumulonimbus', '直展云', 'rare', '垂直发展旺盛的雷暴云,可带来强降水、雷电或冰雹。'),
|
||||||
|
(5, '层积云', 'Stratocumulus', '低云', 'common', '成片或成层的块状云,常有明暗相间的结构。'),
|
||||||
|
(6, '高积云', 'Altocumulus', '中云', 'uncommon', '中高度的小块状或波状云,常成群排列。'),
|
||||||
|
(7, '高层云', 'Altostratus', '中云', 'uncommon', '灰蓝色或灰色的中层云幕,常使太阳呈毛玻璃状。'),
|
||||||
|
(8, '雨层云', 'Nimbostratus', '中云', 'uncommon', '厚重暗灰的降水云层,通常带来持续性降雨或降雪。'),
|
||||||
|
(9, '卷层云', 'Cirrostratus', '高云', 'uncommon', '薄幕状高云,常覆盖大范围天空并产生日晕或月晕。'),
|
||||||
|
(10, '卷积云', 'Cirrocumulus', '高云', 'rare', '细小颗粒状高云,常呈鱼鳞状排列。')
|
||||||
|
on conflict (id) do update set
|
||||||
|
name = excluded.name,
|
||||||
|
name_en = excluded.name_en,
|
||||||
|
genus = excluded.genus,
|
||||||
|
rarity = excluded.rarity,
|
||||||
|
description = excluded.description;
|
||||||
+21
-127
@@ -1,5 +1,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { api } from '@/lib/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useProfileStore } from '@/stores/profile'
|
import { useProfileStore } from '@/stores/profile'
|
||||||
import type { CloudType } from '@/types/database'
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
@@ -170,51 +171,8 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUnlockedTypeIds(userId: string) {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('user_collections')
|
|
||||||
.select('cloud_type_id')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
|
|
||||||
return new Set(
|
|
||||||
(data || [])
|
|
||||||
.map(row => row.cloud_type_id)
|
|
||||||
.filter((cloudTypeId): cloudTypeId is number => typeof cloudTypeId === 'number'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchBadgeDetails(unlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }>) {
|
|
||||||
const ids = unlockedRows.map(item => item.cloudTypeId)
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('cloud_types')
|
|
||||||
.select('id,name,name_en,rarity')
|
|
||||||
.in('id', ids)
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
|
|
||||||
const typeMap = new Map(
|
|
||||||
((data || []) as Array<Pick<CloudType, 'id' | 'name' | 'name_en' | 'rarity'>>).map(item => [item.id, item]),
|
|
||||||
)
|
|
||||||
|
|
||||||
return unlockedRows
|
|
||||||
.map(item => {
|
|
||||||
const cloudType = typeMap.get(item.cloudTypeId)
|
|
||||||
if (!cloudType) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
cloudTypeId: cloudType.id,
|
|
||||||
cloudName: cloudType.name,
|
|
||||||
cloudNameEn: cloudType.name_en,
|
|
||||||
rarity: cloudType.rarity,
|
|
||||||
unlockedAt: item.unlockedAt,
|
|
||||||
} satisfies UnlockedBadge
|
|
||||||
})
|
|
||||||
.filter((item): item is UnlockedBadge => item !== null)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -313,21 +271,14 @@ export function useUpload() {
|
|||||||
currentItemIndex.value = 0
|
currentItemIndex.value = 0
|
||||||
overallProgress.value = 0
|
overallProgress.value = 0
|
||||||
|
|
||||||
const userId = (await supabase.auth.getUser()).data.user?.id
|
const userId = authStore.user?.id
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
return { ok: false, unlockedBadges: [] }
|
return { ok: false, unlockedBadges: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let unlockedTypeIds = new Set<number>()
|
const unlockedBadges: UnlockedBadge[] = []
|
||||||
try {
|
|
||||||
unlockedTypeIds = await fetchUnlockedTypeIds(userId)
|
|
||||||
} catch {
|
|
||||||
unlockedTypeIds = new Set<number>()
|
|
||||||
}
|
|
||||||
|
|
||||||
const newlyUnlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }> = []
|
|
||||||
|
|
||||||
for (let i = 0; i < items.value.length; i++) {
|
for (let i = 0; i < items.value.length; i++) {
|
||||||
const item = items.value[i]
|
const item = items.value[i]
|
||||||
@@ -335,86 +286,29 @@ export function useUpload() {
|
|||||||
|
|
||||||
overallProgress.value = Math.round(i / items.value.length * 100)
|
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 { 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)
|
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 latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
||||||
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
||||||
|
|
||||||
const { data: insertedCloud, error: dbError } = await supabase
|
const form = new FormData()
|
||||||
.from('clouds')
|
form.append('image', item.file)
|
||||||
.insert({
|
form.append('thumbnail', thumbnailFile)
|
||||||
user_id: userId,
|
if (item.cloudCategoryId !== 'other' && item.cloudCategoryId !== null) {
|
||||||
cloud_type_id: item.cloudCategoryId === 'other' ? null : item.cloudCategoryId,
|
form.append('cloud_type_id', String(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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (item.cloudCategoryId === 'other') form.append('custom_cloud_type', item.customCloudType.trim())
|
||||||
|
if (latitude !== null) form.append('latitude', String(latitude))
|
||||||
|
if (longitude !== null) form.append('longitude', String(longitude))
|
||||||
|
if (item.locationName.trim()) form.append('location_name', item.locationName.trim())
|
||||||
|
if (item.description.trim()) form.append('description', item.description.trim())
|
||||||
|
form.append('captured_at', item.capturedAt)
|
||||||
|
form.append('is_hidden', String(item.isHidden))
|
||||||
|
|
||||||
|
const result = await api.clouds.create(form)
|
||||||
|
if (result.unlockedBadge) unlockedBadges.push(result.unlockedBadge)
|
||||||
|
|
||||||
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
|
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
|
||||||
}
|
}
|
||||||
@@ -426,7 +320,7 @@ export function useUpload() {
|
|||||||
profileStore.invalidateUser(userId)
|
profileStore.invalidateUser(userId)
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
|
unlockedBadges,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, unlockedBadges: [] }
|
return { ok: false, unlockedBadges: [] }
|
||||||
|
|||||||
+13
-13
@@ -1,16 +1,16 @@
|
|||||||
import AMapLoader from '@amap/amap-jsapi-loader'
|
import AMapLoader from "@amap/amap-jsapi-loader";
|
||||||
|
|
||||||
export function loadAMap() {
|
export function loadAMap() {
|
||||||
return AMapLoader.load({
|
return AMapLoader.load({
|
||||||
key: import.meta.env.VITE_AMAP_KEY,
|
key: import.meta.env.VITE_AMAP_KEY,
|
||||||
version: '2.0',
|
version: "2.0",
|
||||||
plugins: [
|
plugins: [
|
||||||
'AMap.Scale',
|
"AMap.Scale",
|
||||||
'AMap.ToolBar',
|
"AMap.ToolBar",
|
||||||
'AMap.ControlBar',
|
"AMap.ControlBar",
|
||||||
'AMap.Geolocation',
|
"AMap.Geolocation",
|
||||||
'AMap.Marker',
|
"AMap.Marker",
|
||||||
'AMap.InfoWindow',
|
"AMap.InfoWindow",
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
import type { Cloud, CloudType, Profile, UserCollection } from '@/types/database'
|
||||||
|
|
||||||
|
export interface ApiUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
role: Profile['role']
|
||||||
|
is_disabled: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionEntryResponse extends UserCollection {
|
||||||
|
firstCloud: {
|
||||||
|
id: string
|
||||||
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
|
captured_at: string | null
|
||||||
|
created_at: string
|
||||||
|
location_name: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlockedBadgeResponse {
|
||||||
|
cloudTypeId: number
|
||||||
|
cloudName: string
|
||||||
|
cloudNameEn: string
|
||||||
|
rarity: CloudType['rarity']
|
||||||
|
unlockedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryValue = string | number | boolean | null | undefined
|
||||||
|
|
||||||
|
function buildUrl(path: string, query?: Record<string, QueryValue>) {
|
||||||
|
const url = new URL(path, window.location.origin)
|
||||||
|
if (query) {
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${url.pathname}${url.search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
|
const text = await response.text()
|
||||||
|
const payload = text ? JSON.parse(text) : {}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(payload.message || '请求失败', response.status)
|
||||||
|
}
|
||||||
|
return payload as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet<T>(path: string, query?: Record<string, QueryValue>) {
|
||||||
|
const response = await fetch(buildUrl(path, query), {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
return parseResponse<T>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiJson<T>(path: string, options: {
|
||||||
|
method?: 'POST' | 'PATCH' | 'DELETE'
|
||||||
|
body?: unknown
|
||||||
|
} = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: options.method || 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
})
|
||||||
|
return parseResponse<T>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiForm<T>(path: string, form: FormData) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
return parseResponse<T>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
auth: {
|
||||||
|
me: () => apiGet<{ user: ApiUser | null; profile: Profile | null }>('/api/auth/me'),
|
||||||
|
login: (email: string, password: string) => apiJson<{ user: ApiUser; profile: Profile }>('/api/auth/login', {
|
||||||
|
body: { email, password },
|
||||||
|
}),
|
||||||
|
register: (email: string, password: string, username: string) => apiJson<{ user: ApiUser; profile: Profile }>('/api/auth/register', {
|
||||||
|
body: { email, password, username },
|
||||||
|
}),
|
||||||
|
logout: () => apiJson<{ ok: boolean }>('/api/auth/logout'),
|
||||||
|
updatePassword: (password: string) => apiJson<{ ok: boolean }>('/api/auth/password', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { password },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
checkUsername: (username: string, exclude?: string) => apiGet<{ available: boolean }>('/api/profiles/check-username', {
|
||||||
|
username,
|
||||||
|
exclude,
|
||||||
|
}),
|
||||||
|
get: (id: string) => apiGet<{ profile: Profile }>(`/api/profiles/${id}`),
|
||||||
|
clouds: (id: string, own: boolean) => apiGet<{ data: Cloud[] }>(`/api/profiles/${id}/clouds`, { own }),
|
||||||
|
updateMe: (username: string) => apiJson<{ user: ApiUser; profile: Profile }>('/api/me/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { username },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
cloudTypes: {
|
||||||
|
list: () => apiGet<{ data: CloudType[] }>('/api/cloud-types'),
|
||||||
|
gallery: (id: number) => apiGet<{ data: Cloud[]; count: number }>(`/api/cloud-types/${id}/gallery`),
|
||||||
|
},
|
||||||
|
collections: {
|
||||||
|
mine: () => apiGet<{ data: CollectionEntryResponse[] }>('/api/me/collections'),
|
||||||
|
},
|
||||||
|
clouds: {
|
||||||
|
list: (query: { page: number; pageSize: number; typeId?: number | 'all'; search?: string }) => apiGet<{ data: Cloud[]; count: number }>('/api/clouds', query),
|
||||||
|
map: (query: { field: 'captured_at' | 'created_at'; start: string; end: string }) => apiGet<{ data: Cloud[] }>('/api/clouds/map', query),
|
||||||
|
create: (form: FormData) => apiForm<{ cloud: { id: string; cloud_type_id: number | null }; unlockedBadge: UnlockedBadgeResponse | null }>('/api/clouds', form),
|
||||||
|
update: (id: string, body: Record<string, unknown>) => apiJson<{ cloud: Cloud }>(`/api/clouds/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
delete: (id: string) => apiJson<{ deleted: string[]; fileCleanupFailed: boolean }>(`/api/clouds/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
stats: () => apiGet<{ stats: Record<string, number> }>('/api/admin/stats'),
|
||||||
|
users: () => apiGet<{ data: Profile[] }>('/api/admin/users'),
|
||||||
|
clouds: () => apiGet<{ data: Cloud[] }>('/api/admin/clouds'),
|
||||||
|
updateClouds: (ids: string[], body: { status?: string; is_hidden?: boolean }) => apiJson<{ updated: string[] }>('/api/admin/clouds', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { ids, ...body },
|
||||||
|
}),
|
||||||
|
updateUser: (id: string, body: { role?: Profile['role']; is_disabled?: boolean }) => apiJson<{ profile: Profile }>(`/api/admin/users/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
+25
-98
@@ -1,11 +1,10 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { api, type ApiUser } from '@/lib/api'
|
||||||
import type { User } from '@supabase/supabase-js'
|
|
||||||
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<ApiUser | null>(null)
|
||||||
const profile = ref<Profile | null>(null)
|
const profile = ref<Profile | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
@@ -15,74 +14,32 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
async function fetchProfile(userId?: string) {
|
async function fetchProfile(userId?: string) {
|
||||||
const id = userId ?? user.value?.id
|
const id = userId ?? user.value?.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
const { data } = await supabase
|
const { profile: nextProfile } = await api.profiles.get(id)
|
||||||
.from('profiles')
|
profile.value = nextProfile
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.single()
|
|
||||||
if (data) {
|
|
||||||
profile.value = data as Profile
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureUsernameAvailable(username: string, currentUserId?: string) {
|
async function ensureUsernameAvailable(username: string, currentUserId?: string) {
|
||||||
let query = supabase
|
const { available } = await api.profiles.checkUsername(username, currentUserId)
|
||||||
.from('profiles')
|
if (!available) {
|
||||||
.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('这个昵称已经被使用,请换一个。')
|
throw new Error('这个昵称已经被使用,请换一个。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(email: string, password: string) {
|
async function login(email: string, password: string) {
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
const data = await api.auth.login(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
|
user.value = data.user
|
||||||
|
profile.value = data.profile
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(email: string, password: string, username: string) {
|
async function register(email: string, password: string, username: string) {
|
||||||
await ensureUsernameAvailable(username)
|
await ensureUsernameAvailable(username)
|
||||||
|
const data = await api.auth.register(email, password, username)
|
||||||
const { error } = await supabase.auth.signUp({
|
user.value = data.user
|
||||||
email,
|
profile.value = data.profile
|
||||||
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() {
|
async function logout() {
|
||||||
const { error } = await supabase.auth.signOut()
|
await api.auth.logout()
|
||||||
if (error) throw error
|
|
||||||
user.value = null
|
user.value = null
|
||||||
profile.value = null
|
profile.value = null
|
||||||
}
|
}
|
||||||
@@ -92,58 +49,28 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
await ensureUsernameAvailable(username, user.value.id)
|
await ensureUsernameAvailable(username, user.value.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const data = await api.profiles.updateMe(username)
|
||||||
.from('profiles')
|
user.value = data.user
|
||||||
.update({ username })
|
profile.value = data.profile
|
||||||
.eq('id', user.value.id)
|
|
||||||
.select('*')
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (error.code === '23505') {
|
|
||||||
throw new Error('这个昵称已经被使用,请换一个。')
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
profile.value = data as Profile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePassword(password: string) {
|
async function updatePassword(password: string) {
|
||||||
const { error } = await supabase.auth.updateUser({ password })
|
await api.auth.updatePassword(password)
|
||||||
if (error) throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPasswordReset(email: string) {
|
async function sendPasswordReset(email: string) {
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
void email
|
||||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
throw new Error('当前本地账号系统暂未启用邮件找回密码,请登录后在个人设置中修改密码,或联系管理员重置。')
|
||||||
})
|
|
||||||
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()
|
try {
|
||||||
const initialUser = session?.user ?? null
|
const data = await api.auth.me()
|
||||||
if (initialUser) await fetchProfile(initialUser.id)
|
user.value = data.user
|
||||||
user.value = initialUser
|
profile.value = data.profile
|
||||||
loading.value = false
|
} finally {
|
||||||
|
loading.value = false
|
||||||
supabase.auth.onAuthStateChange((_event, session) => {
|
}
|
||||||
const nextUser = session?.user ?? null
|
|
||||||
if (nextUser) {
|
|
||||||
fetchProfile(nextUser.id).then(() => {
|
|
||||||
user.value = nextUser
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
user.value = null
|
|
||||||
profile.value = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { api } 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,12 +10,9 @@ 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
|
const { data } = await api.cloudTypes.list()
|
||||||
.from('cloud_types')
|
|
||||||
.select('*')
|
|
||||||
.order('id')
|
|
||||||
if (data) {
|
if (data) {
|
||||||
cloudTypes.value = data as CloudType[]
|
cloudTypes.value = data
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { api } from '@/lib/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { CloudType, UserCollection } from '@/types/database'
|
import type { CloudType, UserCollection } from '@/types/database'
|
||||||
|
|
||||||
@@ -25,22 +25,6 @@ function getErrorMessage(error: unknown) {
|
|||||||
return '图鉴收藏加载失败'
|
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', () => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
@@ -68,14 +52,8 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
|||||||
|
|
||||||
loadingCloudTypes.value = true
|
loadingCloudTypes.value = true
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data } = await api.cloudTypes.list()
|
||||||
.from('cloud_types')
|
cloudTypes.value = data || []
|
||||||
.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
|
||||||
@@ -98,36 +76,8 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
|||||||
collectionError.value = ''
|
collectionError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data } = await api.collections.mine()
|
||||||
.from('user_collections')
|
myCollection.value = data
|
||||||
.select('*')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.order('unlocked_at', { ascending: true })
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
|
|
||||||
const collectionRows = (data || []) as UserCollection[]
|
|
||||||
const firstCloudIds = collectionRows
|
|
||||||
.map(item => item.first_cloud_id)
|
|
||||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
||||||
|
|
||||||
let firstCloudMap = new Map<string, CollectionPreviewCloud>()
|
|
||||||
|
|
||||||
if (firstCloudIds.length) {
|
|
||||||
const { data: firstCloudData, error: firstCloudError } = await supabase
|
|
||||||
.from('clouds')
|
|
||||||
.select('id,image_url,thumbnail_url,captured_at,created_at,location_name')
|
|
||||||
.in('id', firstCloudIds)
|
|
||||||
|
|
||||||
if (firstCloudError) throw firstCloudError
|
|
||||||
|
|
||||||
firstCloudMap = toCollectionCloudMap(firstCloudData as Array<Record<string, unknown>> | null)
|
|
||||||
}
|
|
||||||
|
|
||||||
myCollection.value = collectionRows.map(item => ({
|
|
||||||
...item,
|
|
||||||
firstCloud: item.first_cloud_id ? firstCloudMap.get(item.first_cloud_id) ?? null : null,
|
|
||||||
}))
|
|
||||||
collectionLoadedForUserId.value = userId
|
collectionLoadedForUserId.value = userId
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
myCollection.value = []
|
myCollection.value = []
|
||||||
|
|||||||
+13
-106
@@ -1,6 +1,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { api } from '@/lib/api'
|
||||||
import type { CloudType, Profile } from '@/types/database'
|
import type { CloudType, Profile } from '@/types/database'
|
||||||
|
|
||||||
export interface ProfileCloudItem {
|
export interface ProfileCloudItem {
|
||||||
@@ -44,28 +44,6 @@ function toProfileCloud(row: Record<string, unknown>) {
|
|||||||
} satisfies ProfileCloudItem
|
} satisfies ProfileCloudItem
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSupabaseErrorCode(error: unknown) {
|
|
||||||
if (!error || typeof error !== 'object' || !('code' in error)) return null
|
|
||||||
const code = (error as { code?: unknown }).code
|
|
||||||
return typeof code === 'string' ? code : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCloudStoragePath(publicUrl: string | null) {
|
|
||||||
if (!publicUrl) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(publicUrl)
|
|
||||||
const marker = '/storage/v1/object/public/clouds/'
|
|
||||||
const markerIndex = url.pathname.indexOf(marker)
|
|
||||||
if (markerIndex === -1) return null
|
|
||||||
|
|
||||||
const path = url.pathname.slice(markerIndex + marker.length)
|
|
||||||
return path ? decodeURIComponent(path) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useProfileStore = defineStore('profile-page', () => {
|
export const useProfileStore = defineStore('profile-page', () => {
|
||||||
const profilesById = ref<Record<string, Profile>>({})
|
const profilesById = ref<Record<string, Profile>>({})
|
||||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||||
@@ -102,32 +80,13 @@ export const useProfileStore = defineStore('profile-page', () => {
|
|||||||
errorByKey.value[key] = ''
|
errorByKey.value[key] = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: profile, error: profileError } = await supabase
|
const [{ profile }, { data: cloudRows }] = await Promise.all([
|
||||||
.from('profiles')
|
api.profiles.get(userId),
|
||||||
.select('*')
|
api.profiles.clouds(userId, isOwnProfile),
|
||||||
.eq('id', userId)
|
])
|
||||||
.single()
|
|
||||||
|
|
||||||
if (profileError) throw profileError
|
profilesById.value[userId] = profile
|
||||||
profilesById.value[userId] = profile as Profile
|
cloudsByKey.value[key] = ((cloudRows || []) as unknown as Array<Record<string, unknown>>).map(toProfileCloud)
|
||||||
|
|
||||||
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] = []
|
||||||
@@ -178,76 +137,24 @@ 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 { cloud } = await api.clouds.update(cloudId, patch)
|
||||||
.from('clouds')
|
const updated = toProfileCloud(cloud as unknown as Record<string, unknown>)
|
||||||
.update(patch)
|
|
||||||
.eq('id', cloudId)
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
|
|
||||||
const updated = toProfileCloud(data as Record<string, unknown>)
|
|
||||||
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 api.clouds.update(cloudId, { is_hidden: isHidden })
|
||||||
.from('clouds')
|
|
||||||
.update({ is_hidden: isHidden })
|
|
||||||
.eq('id', cloudId)
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.select('id')
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
if (!data?.length) {
|
|
||||||
throw new Error('私密状态没有写入数据库,请检查 clouds 表的 UPDATE RLS policy。')
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
const results = await Promise.all(cloudIds.map(id => api.clouds.delete(id)))
|
||||||
.from('clouds')
|
const deletedIds = results.flatMap(result => result.deleted)
|
||||||
.delete()
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.in('id', cloudIds)
|
|
||||||
.select('id,image_url,thumbnail_url')
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (getSupabaseErrorCode(error) === '23503') {
|
|
||||||
throw new Error('这张照片仍被图鉴收藏记录引用,请先在数据库把 user_collections.first_cloud_id 外键改为 ON DELETE SET NULL。')
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedIds = (data || []).map(item => item.id as string)
|
|
||||||
if (deletedIds.length !== cloudIds.length) {
|
if (deletedIds.length !== cloudIds.length) {
|
||||||
throw new Error('图片没有真正从数据库删除,请检查 clouds 表的 DELETE RLS policy。')
|
throw new Error('图片没有完全删除,请刷新后重试。')
|
||||||
}
|
|
||||||
|
|
||||||
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, deletedIds)
|
||||||
|
|||||||
+24
-120
@@ -4,7 +4,7 @@ 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 { api } from '@/lib/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useProfileStore } from '@/stores/profile'
|
import { useProfileStore } from '@/stores/profile'
|
||||||
import type { CloudType, Profile } from '@/types/database'
|
import type { CloudType, Profile } from '@/types/database'
|
||||||
@@ -172,88 +172,27 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
return parts.length ? parts.join(';') : fallback
|
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 api.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 || 0,
|
||||||
images: imageCount,
|
images: stats.images || 0,
|
||||||
todayUploads,
|
todayUploads: stats.todayUploads || 0,
|
||||||
pending: pendingCount,
|
pending: stats.pending || 0,
|
||||||
approved: approvedCount,
|
approved: stats.approved || 0,
|
||||||
rejected: rejectedCount,
|
rejected: stats.rejected || 0,
|
||||||
hidden: hiddenCount,
|
hidden: stats.hidden || 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers() {
|
||||||
const { data, error } = await supabase
|
const { data } = await api.admin.users()
|
||||||
.from('profiles')
|
users.value = data || []
|
||||||
.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 { data } = await api.admin.clouds()
|
||||||
.from('clouds')
|
images.value = ((data || []) as unknown as Array<Record<string, unknown>>).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)))
|
||||||
}
|
}
|
||||||
@@ -334,18 +273,9 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = supabase
|
const { updated } = await api.admin.updateClouds(ids, { status })
|
||||||
.from('clouds')
|
if (updated.length !== ids.length) {
|
||||||
.update({ status })
|
throw new Error('部分图片状态没有写入数据库。')
|
||||||
.select('id')
|
|
||||||
|
|
||||||
query = ids.length === 1 ? query.eq('id', ids[0]) : query.in('id', ids)
|
|
||||||
|
|
||||||
const { data, error } = await query
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
if ((data || []).length !== ids.length) {
|
|
||||||
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
patchImages(ids, { status })
|
patchImages(ids, { status })
|
||||||
@@ -369,15 +299,9 @@ async function updateImageVisibility(ids: string[], isHidden: boolean) {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { updated } = await api.admin.updateClouds(ids, { is_hidden: isHidden })
|
||||||
.from('clouds')
|
if (updated.length !== ids.length) {
|
||||||
.update({ is_hidden: isHidden })
|
throw new Error('图片可见性没有写入数据库。')
|
||||||
.in('id', ids)
|
|
||||||
.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 })
|
||||||
@@ -455,18 +379,8 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { profile } = await api.admin.updateUser(user.id, { role })
|
||||||
.from('profiles')
|
users.value = users.value.map(item => (item.id === user.id ? profile : item))
|
||||||
.update({ role })
|
|
||||||
.eq('id', user.id)
|
|
||||||
.select('id,role')
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
if (!data?.length) {
|
|
||||||
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
|
||||||
}
|
|
||||||
|
|
||||||
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
|
|
||||||
message.success('用户角色已更新')
|
message.success('用户角色已更新')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '用户角色更新失败')
|
const text = getErrorMessage(error, '用户角色更新失败')
|
||||||
@@ -488,18 +402,8 @@ async function toggleUserDisabled(user: Profile) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const nextDisabled = !user.is_disabled
|
const nextDisabled = !user.is_disabled
|
||||||
const { data, error } = await supabase
|
const { profile } = await api.admin.updateUser(user.id, { is_disabled: nextDisabled })
|
||||||
.from('profiles')
|
users.value = users.value.map(item => (item.id === user.id ? profile : item))
|
||||||
.update({ is_disabled: nextDisabled })
|
|
||||||
.eq('id', user.id)
|
|
||||||
.select('id,is_disabled')
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
if (!data?.length) {
|
|
||||||
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
|
|
||||||
}
|
|
||||||
|
|
||||||
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
|
|
||||||
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '用户状态更新失败')
|
const text = getErrorMessage(error, '用户状态更新失败')
|
||||||
@@ -522,7 +426,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。
|
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都通过本地后端 API 校验后落库。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,85 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { NButton, NCard, NResult } from 'naive-ui'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
|
||||||
import { createDetachedAuthClient, resolveEmailAuthCallback } from '@/lib/authEmail'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
|
||||||
const countdown = ref(5)
|
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function startCountdown() {
|
|
||||||
countdownTimer = setInterval(() => {
|
|
||||||
countdown.value--
|
|
||||||
if (countdown.value <= 0) {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const confirmClient = createDetachedAuthClient()
|
|
||||||
const result = await resolveEmailAuthCallback({
|
|
||||||
client: confirmClient,
|
|
||||||
allowedTypes: ['signup', 'email'],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
state.value = 'failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await confirmClient.auth.signOut()
|
|
||||||
state.value = 'success'
|
|
||||||
startCountdown()
|
|
||||||
} catch {
|
|
||||||
state.value = 'failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<template v-if="state === 'success'">
|
<NResult
|
||||||
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
|
status="info"
|
||||||
|
title="本地账号无需邮箱确认"
|
||||||
|
description="OpenCloud 当前使用本地 PostgreSQL 账号系统,注册后可以直接登录。"
|
||||||
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="space-y-4">
|
<RouterLink to="/login" class="no-underline">
|
||||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
|
||||||
<RouterLink to="/login" class="no-underline">
|
|
||||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">立即登录</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="state === 'failed'">
|
|
||||||
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
|
|
||||||
<template #footer>
|
|
||||||
<RouterLink to="/register" class="no-underline">
|
|
||||||
<NButton type="primary" class="oc-primary-button oc-primary-button--sky">重新注册</NButton>
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<NSpin size="large" />
|
|
||||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证...</h1>
|
|
||||||
<p class="mt-2 text-slate-500">请稍候</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,18 +81,18 @@ onUnmounted(() => {
|
|||||||
<span class="block text-sky-700">OpenCloud 账号</span>
|
<span class="block text-sky-700">OpenCloud 账号</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||||
输入注册邮箱后,我们会发送一封密码重置邮件。打开邮件中的链接,即可进入新的密码设置页面。
|
当前本地账号系统暂未启用邮件找回密码。已登录用户可以在个人设置中修改密码;无法登录时请联系管理员处理。
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Step 1</div>
|
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Step 1</div>
|
||||||
<div class="mt-2 text-lg font-bold text-slate-900">发送邮件</div>
|
<div class="mt-2 text-lg font-bold text-slate-900">联系管理员</div>
|
||||||
<div class="mt-1 text-sm text-slate-500">使用注册邮箱请求重置链接</div>
|
<div class="mt-1 text-sm text-slate-500">确认账号归属后处理</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Step 2</div>
|
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Step 2</div>
|
||||||
<div class="mt-2 text-lg font-bold text-slate-900">设置新密码</div>
|
<div class="mt-2 text-lg font-bold text-slate-900">个人设置</div>
|
||||||
<div class="mt-1 text-sm text-slate-500">在专用页面完成新密码更新</div>
|
<div class="mt-1 text-sm text-slate-500">登录后可自行修改密码</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -101,8 +101,8 @@ onUnmounted(() => {
|
|||||||
<NResult
|
<NResult
|
||||||
v-if="submitted"
|
v-if="submitted"
|
||||||
status="success"
|
status="success"
|
||||||
title="重置邮件已发送"
|
title="暂未启用邮件重置"
|
||||||
description="如果该邮箱已注册,请查收邮件中的重置链接。"
|
description="当前版本不会发送密码重置邮件,请联系管理员或登录后在个人设置中修改。"
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -132,7 +132,7 @@ onUnmounted(() => {
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Reset Request</div>
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Reset Request</div>
|
||||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h2>
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h2>
|
||||||
<p class="mt-2 text-sm text-slate-500">输入你的注册邮箱,我们会发送一封密码重置邮件。</p>
|
<p class="mt-2 text-sm text-slate-500">当前版本暂未启用邮件发送;提交后会显示处理说明。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NForm @submit.prevent="handleSendResetEmail">
|
<NForm @submit.prevent="handleSendResetEmail">
|
||||||
|
|||||||
@@ -77,13 +77,13 @@ async function handleRegister() {
|
|||||||
<NResult
|
<NResult
|
||||||
v-if="emailSent"
|
v-if="emailSent"
|
||||||
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">
|
<p class="text-sm text-slate-500">
|
||||||
目标邮箱:
|
注册邮箱:
|
||||||
<span class="font-semibold text-slate-700">{{ email }}</span>
|
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||||
</p>
|
</p>
|
||||||
<RouterLink to="/login" class="no-underline">
|
<RouterLink to="/login" class="no-underline">
|
||||||
|
|||||||
@@ -1,118 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { NButton, NCard, NResult } from 'naive-ui'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
|
|
||||||
import { resolveEmailAuthCallback } from '@/lib/authEmail'
|
|
||||||
import { supabase } from '@/lib/supabase'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
|
|
||||||
const loading = ref(false)
|
|
||||||
const countdown = ref(5)
|
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
|
||||||
state.value === 'ready' && password.value.length >= 6 && password.value === confirmPassword.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
function showInvalidRecoveryLink() {
|
|
||||||
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
|
|
||||||
state.value = 'invalid'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResetErrorMessage(value: unknown) {
|
|
||||||
const message = value instanceof Error ? value.message : ''
|
|
||||||
if (
|
|
||||||
message.includes('Auth session missing')
|
|
||||||
|| message.includes('session_not_found')
|
|
||||||
|| message.includes('refresh_token_not_found')
|
|
||||||
|| message.includes('Invalid Refresh Token')
|
|
||||||
|| message.includes('otp_expired')
|
|
||||||
|| message.includes('expired')
|
|
||||||
) {
|
|
||||||
return '密码重置链接无效或已过期,请重新发送邮件。'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('Password should be at least')) {
|
|
||||||
return '新密码至少需要 6 位。'
|
|
||||||
}
|
|
||||||
|
|
||||||
return message || '密码重置失败,请稍后重试。'
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdown() {
|
|
||||||
countdownTimer = setInterval(() => {
|
|
||||||
countdown.value--
|
|
||||||
if (countdown.value <= 0) {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeRecoverySession() {
|
|
||||||
const result = await resolveEmailAuthCallback({
|
|
||||||
client: supabase,
|
|
||||||
allowedTypes: ['recovery'],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.ok || !result.session?.user) {
|
|
||||||
error.value = getResetErrorMessage(result.ok ? '链接无效或已过期。' : result.error)
|
|
||||||
state.value = 'invalid'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.value = 'ready'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetPassword() {
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
if (password.value.length < 6) {
|
|
||||||
error.value = '新密码至少需要 6 位。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.value !== confirmPassword.value) {
|
|
||||||
error.value = '两次输入的密码不一致。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
|
|
||||||
if (sessionError || !session?.user) {
|
|
||||||
showInvalidRecoveryLink()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const { error: updateError } = await supabase.auth.updateUser({ password: password.value })
|
|
||||||
if (updateError) throw updateError
|
|
||||||
|
|
||||||
window.history.replaceState(null, '', window.location.pathname)
|
|
||||||
countdown.value = 5
|
|
||||||
state.value = 'success'
|
|
||||||
startCountdown()
|
|
||||||
} catch (e) {
|
|
||||||
error.value = getResetErrorMessage(e)
|
|
||||||
if (error.value === '密码重置链接无效或已过期,请重新发送邮件。') {
|
|
||||||
state.value = 'invalid'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void initializeRecoverySession()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -120,88 +7,21 @@ onUnmounted(() => {
|
|||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<NResult
|
<NResult
|
||||||
v-if="state === 'success'"
|
status="warning"
|
||||||
status="success"
|
title="邮件重置暂未启用"
|
||||||
title="密码已重置"
|
description="迁移到本地账号系统后,当前版本暂不发送密码重置邮件。已登录用户可在个人资料设置中修改密码。"
|
||||||
description="你已使用新密码完成更新,正在返回地图页。"
|
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="space-y-4">
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转地图页面...</p>
|
<RouterLink to="/login" class="no-underline">
|
||||||
<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">返回登录</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/profile/settings" class="no-underline">
|
||||||
|
<NButton type="default" class="oc-panel-button oc-panel-button--neutral">个人设置</NButton>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
|
|
||||||
<template v-else-if="state === 'checking'">
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<NSpin size="large" />
|
|
||||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证重置链接...</h1>
|
|
||||||
<p class="mt-2 text-slate-500">请稍候</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
|
||||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
|
||||||
{{
|
|
||||||
state === 'invalid'
|
|
||||||
? '当前重置链接不可用,请重新发送密码重置邮件。'
|
|
||||||
: '请输入新的登录密码,提交后当前重置链接会失效。'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NForm @submit.prevent="handleResetPassword">
|
|
||||||
<NFormItem label="新密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
show-password-on="click"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="至少 6 位"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="确认新密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
show-password-on="click"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="再次输入新密码"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
v-if="state === 'invalid'"
|
|
||||||
to="/forgot-password"
|
|
||||||
class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800"
|
|
||||||
>
|
|
||||||
返回重新发送重置邮件
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--teal"
|
|
||||||
:disabled="!canSubmit"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
保存新密码
|
|
||||||
</NButton>
|
|
||||||
</NForm>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 { api } 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 { CloudType } from '@/types/database'
|
import type { CloudType } from '@/types/database'
|
||||||
@@ -82,32 +82,9 @@ function openGalleryDetail(item: CloudGalleryItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadGallery(typeId: number) {
|
async function loadGallery(typeId: number) {
|
||||||
const galleryQuery = supabase
|
const { data: galleryData, count } = await api.cloudTypes.gallery(typeId)
|
||||||
.from('clouds')
|
|
||||||
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,profiles(username)')
|
|
||||||
.eq('cloud_type_id', typeId)
|
|
||||||
.eq('status', 'approved')
|
|
||||||
.eq('is_hidden', false)
|
|
||||||
.order('captured_at', { ascending: false, nullsFirst: false })
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(24)
|
|
||||||
|
|
||||||
const countQuery = supabase
|
gallery.value = ((galleryData || []) as unknown as Array<Record<string, unknown>>).map(row => {
|
||||||
.from('clouds')
|
|
||||||
.select('*', { head: true, count: 'exact' })
|
|
||||||
.eq('cloud_type_id', typeId)
|
|
||||||
.eq('status', 'approved')
|
|
||||||
.eq('is_hidden', false)
|
|
||||||
|
|
||||||
const [{ data: galleryData, error: galleryError }, { count, error: countError }] = await Promise.all([
|
|
||||||
galleryQuery,
|
|
||||||
countQuery,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (galleryError) throw galleryError
|
|
||||||
if (countError) throw countError
|
|
||||||
|
|
||||||
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => {
|
|
||||||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||||
const profile = profiles[0] as Record<string, unknown> | undefined
|
const profile = profiles[0] as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 { api } 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'
|
||||||
@@ -87,34 +87,6 @@ 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) {
|
|
||||||
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>) {
|
function toGalleryCloud(row: Record<string, unknown>) {
|
||||||
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
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 profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||||
@@ -142,48 +114,12 @@ function toGalleryCloud(row: Record<string, unknown>) {
|
|||||||
} 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()
|
if (isUserSearch.value && !normalizedSearch.value.slice(1).trim()) {
|
||||||
if (filters === null) {
|
|
||||||
galleryItems.value = []
|
galleryItems.value = []
|
||||||
totalCount.value = 0
|
totalCount.value = 0
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
@@ -191,29 +127,12 @@ async function loadPage(page: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true })
|
const { data, count } = await api.clouds.list({
|
||||||
let dataQuery = buildFilteredQuery(FULL_SELECT)
|
page,
|
||||||
.order('created_at', { ascending: false })
|
pageSize: PAGE_SIZE,
|
||||||
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1)
|
typeId: selectedTypeId.value,
|
||||||
|
search: normalizedSearch.value,
|
||||||
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
|
totalCount.value = count ?? 0
|
||||||
galleryItems.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toGalleryCloud)
|
galleryItems.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toGalleryCloud)
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
|
|||||||
+10
-17
@@ -3,7 +3,7 @@ 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 { api } from '@/lib/api'
|
||||||
import { loadAMap } from '@/lib/amap'
|
import { loadAMap } from '@/lib/amap'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { NIcon } from 'naive-ui'
|
import { NIcon } from 'naive-ui'
|
||||||
@@ -232,24 +232,17 @@ function toCloudMarker(row: Record<string, unknown>): CloudMarkerData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
try {
|
||||||
.from('clouds')
|
const { data } = await api.clouds.map({
|
||||||
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
field,
|
||||||
.eq('status', 'approved')
|
start: start.toISOString(),
|
||||||
.eq('is_hidden', false)
|
end: end.toISOString(),
|
||||||
.not('latitude', 'is', null)
|
})
|
||||||
.not('longitude', 'is', null)
|
return ((data || []) as unknown as Array<Record<string, unknown>>).map(toCloudMarker)
|
||||||
.gte(field, start.toISOString())
|
} catch (error) {
|
||||||
.lt(field, end.toISOString())
|
statusText.value = `查询失败: ${error instanceof Error ? error.message : '地图数据加载失败'}`
|
||||||
.order(field, { ascending: true })
|
|
||||||
.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() {
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ function seoFilesPlugin() {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), tailwindcss(), seoFilesPlugin()],
|
plugins: [vue(), tailwindcss(), seoFilesPlugin()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3001',
|
||||||
|
'/uploads': 'http://localhost:3001',
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user