Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6359231b99 |
@@ -24,3 +24,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.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",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:api": "tsx server/index.ts",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@supabase/supabase-js": "^2.106.1",
|
||||
"@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",
|
||||
"pg": "^8.21.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/pg": "^8.20.0",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vicons/tabler": "^0.13.0",
|
||||
"@vicons/utils": "^0.1.4",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"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 { supabase } from '@/lib/supabase'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
@@ -170,51 +171,8 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchUnlockedTypeIds(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('user_collections')
|
||||
.select('cloud_type_id')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return new Set(
|
||||
(data || [])
|
||||
.map(row => row.cloud_type_id)
|
||||
.filter((cloudTypeId): cloudTypeId is number => typeof cloudTypeId === 'number'),
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchBadgeDetails(unlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }>) {
|
||||
const ids = unlockedRows.map(item => item.cloudTypeId)
|
||||
const { data, error } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('id,name,name_en,rarity')
|
||||
.in('id', ids)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const typeMap = new Map(
|
||||
((data || []) as Array<Pick<CloudType, 'id' | 'name' | 'name_en' | 'rarity'>>).map(item => [item.id, item]),
|
||||
)
|
||||
|
||||
return unlockedRows
|
||||
.map(item => {
|
||||
const cloudType = typeMap.get(item.cloudTypeId)
|
||||
if (!cloudType) return null
|
||||
|
||||
return {
|
||||
cloudTypeId: cloudType.id,
|
||||
cloudName: cloudType.name,
|
||||
cloudNameEn: cloudType.name_en,
|
||||
rarity: cloudType.rarity,
|
||||
unlockedAt: item.unlockedAt,
|
||||
} satisfies UnlockedBadge
|
||||
})
|
||||
.filter((item): item is UnlockedBadge => item !== null)
|
||||
}
|
||||
|
||||
export function useUpload() {
|
||||
const authStore = useAuthStore()
|
||||
const profileStore = useProfileStore()
|
||||
const items = ref<UploadItem[]>([])
|
||||
const uploading = ref(false)
|
||||
@@ -313,21 +271,14 @@ export function useUpload() {
|
||||
currentItemIndex.value = 0
|
||||
overallProgress.value = 0
|
||||
|
||||
const userId = (await supabase.auth.getUser()).data.user?.id
|
||||
const userId = authStore.user?.id
|
||||
if (!userId) {
|
||||
uploading.value = false
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
let unlockedTypeIds = new Set<number>()
|
||||
try {
|
||||
unlockedTypeIds = await fetchUnlockedTypeIds(userId)
|
||||
} catch {
|
||||
unlockedTypeIds = new Set<number>()
|
||||
}
|
||||
|
||||
const newlyUnlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }> = []
|
||||
const unlockedBadges: UnlockedBadge[] = []
|
||||
|
||||
for (let i = 0; i < items.value.length; i++) {
|
||||
const item = items.value[i]
|
||||
@@ -335,86 +286,29 @@ export function useUpload() {
|
||||
|
||||
overallProgress.value = Math.round(i / items.value.length * 100)
|
||||
|
||||
const basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const ext = item.file.name.split('.').pop() || 'jpg'
|
||||
const imagePath = `${basePath}.${ext}`
|
||||
const thumbnailPath = `${basePath}-thumb.jpg`
|
||||
const { thumbnailFile } = await createUploadAssets(item.file)
|
||||
|
||||
const { error: imgError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.upload(imagePath, item.file, {
|
||||
upsert: false,
|
||||
contentType: item.file.type,
|
||||
})
|
||||
if (imgError) throw imgError
|
||||
|
||||
const { error: thumbError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.upload(thumbnailPath, thumbnailFile, {
|
||||
upsert: false,
|
||||
contentType: thumbnailFile.type,
|
||||
})
|
||||
if (thumbError) throw thumbError
|
||||
|
||||
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
|
||||
|
||||
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
|
||||
const { data: { publicUrl: thumbnailUrl } } = supabase.storage.from('clouds').getPublicUrl(thumbnailPath)
|
||||
|
||||
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
||||
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
||||
|
||||
const { data: insertedCloud, error: dbError } = await supabase
|
||||
.from('clouds')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
cloud_type_id: item.cloudCategoryId === 'other' ? null : item.cloudCategoryId,
|
||||
custom_cloud_type: item.cloudCategoryId === 'other' ? (item.customCloudType.trim() || null) : null,
|
||||
image_url: imageUrl,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
latitude,
|
||||
longitude,
|
||||
location_name: item.locationName || null,
|
||||
description: item.description.trim() || null,
|
||||
captured_at: item.capturedAt,
|
||||
status: 'pending',
|
||||
is_hidden: item.isHidden,
|
||||
})
|
||||
.select('id,cloud_type_id')
|
||||
.single()
|
||||
if (dbError) throw dbError
|
||||
const form = new FormData()
|
||||
form.append('image', item.file)
|
||||
form.append('thumbnail', thumbnailFile)
|
||||
if (item.cloudCategoryId !== 'other' && item.cloudCategoryId !== null) {
|
||||
form.append('cloud_type_id', String(item.cloudCategoryId))
|
||||
}
|
||||
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))
|
||||
|
||||
if (
|
||||
insertedCloud &&
|
||||
typeof insertedCloud.cloud_type_id === 'number' &&
|
||||
!unlockedTypeIds.has(insertedCloud.cloud_type_id)
|
||||
) {
|
||||
try {
|
||||
const { data: collectionRow, error: collectionError } = await supabase
|
||||
.from('user_collections')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
cloud_type_id: insertedCloud.cloud_type_id,
|
||||
first_cloud_id: insertedCloud.id,
|
||||
})
|
||||
.select('cloud_type_id,unlocked_at')
|
||||
.single()
|
||||
|
||||
if (collectionError) {
|
||||
const duplicate = collectionError.code === '23505'
|
||||
if (!duplicate) throw collectionError
|
||||
} else if (collectionRow) {
|
||||
unlockedTypeIds.add(collectionRow.cloud_type_id)
|
||||
newlyUnlockedRows.push({
|
||||
cloudTypeId: collectionRow.cloud_type_id,
|
||||
unlockedAt: collectionRow.unlocked_at,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore collection sync failures so uploads can still complete.
|
||||
}
|
||||
}
|
||||
const result = await api.clouds.create(form)
|
||||
if (result.unlockedBadge) unlockedBadges.push(result.unlockedBadge)
|
||||
|
||||
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
|
||||
}
|
||||
@@ -426,7 +320,7 @@ export function useUpload() {
|
||||
profileStore.invalidateUser(userId)
|
||||
return {
|
||||
ok: true,
|
||||
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
|
||||
unlockedBadges,
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
|
||||
+9
-9
@@ -1,16 +1,16 @@
|
||||
import AMapLoader from '@amap/amap-jsapi-loader'
|
||||
import AMapLoader from "@amap/amap-jsapi-loader";
|
||||
|
||||
export function loadAMap() {
|
||||
return AMapLoader.load({
|
||||
key: import.meta.env.VITE_AMAP_KEY,
|
||||
version: '2.0',
|
||||
version: "2.0",
|
||||
plugins: [
|
||||
'AMap.Scale',
|
||||
'AMap.ToolBar',
|
||||
'AMap.ControlBar',
|
||||
'AMap.Geolocation',
|
||||
'AMap.Marker',
|
||||
'AMap.InfoWindow',
|
||||
"AMap.Scale",
|
||||
"AMap.ToolBar",
|
||||
"AMap.ControlBar",
|
||||
"AMap.Geolocation",
|
||||
"AMap.Marker",
|
||||
"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,
|
||||
},
|
||||
})
|
||||
+23
-96
@@ -1,11 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import { api, type ApiUser } from '@/lib/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const user = ref<ApiUser | null>(null)
|
||||
const profile = ref<Profile | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
@@ -15,74 +14,32 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
async function fetchProfile(userId?: string) {
|
||||
const id = userId ?? user.value?.id
|
||||
if (!id) return
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
if (data) {
|
||||
profile.value = data as Profile
|
||||
}
|
||||
const { profile: nextProfile } = await api.profiles.get(id)
|
||||
profile.value = nextProfile
|
||||
}
|
||||
|
||||
async function ensureUsernameAvailable(username: string, currentUserId?: string) {
|
||||
let query = supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('username', username)
|
||||
.limit(1)
|
||||
|
||||
if (currentUserId) {
|
||||
query = query.neq('id', currentUserId)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
if (data?.length) {
|
||||
const { available } = await api.profiles.checkUsername(username, currentUserId)
|
||||
if (!available) {
|
||||
throw new Error('这个昵称已经被使用,请换一个。')
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (error) {
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
throw new Error('邮箱或密码错误')
|
||||
}
|
||||
if (error.message.includes('Email not confirmed')) {
|
||||
throw new Error('邮箱未确认,请先查收确认邮件')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await fetchProfile(data.user.id)
|
||||
const data = await api.auth.login(email, password)
|
||||
user.value = data.user
|
||||
profile.value = data.profile
|
||||
}
|
||||
|
||||
async function register(email: string, password: string, username: string) {
|
||||
await ensureUsernameAvailable(username)
|
||||
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: { username },
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
if (error.message.includes('already registered')) {
|
||||
throw new Error('该邮箱已被注册')
|
||||
}
|
||||
if (error.message.includes('Password')) {
|
||||
throw new Error('密码强度不足,请使用至少6位密码')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const data = await api.auth.register(email, password, username)
|
||||
user.value = data.user
|
||||
profile.value = data.profile
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) throw error
|
||||
await api.auth.logout()
|
||||
user.value = null
|
||||
profile.value = null
|
||||
}
|
||||
@@ -92,58 +49,28 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
await ensureUsernameAvailable(username, user.value.id)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ username })
|
||||
.eq('id', user.value.id)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
throw new Error('这个昵称已经被使用,请换一个。')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
profile.value = data as Profile
|
||||
const data = await api.profiles.updateMe(username)
|
||||
user.value = data.user
|
||||
profile.value = data.profile
|
||||
}
|
||||
|
||||
async function updatePassword(password: string) {
|
||||
const { error } = await supabase.auth.updateUser({ password })
|
||||
if (error) throw error
|
||||
await api.auth.updatePassword(password)
|
||||
}
|
||||
|
||||
async function sendPasswordReset(email: string) {
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||
})
|
||||
if (error) {
|
||||
if (error.message.includes('rate limit')) {
|
||||
throw new Error('发送过于频繁,请稍后再试。')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
void email
|
||||
throw new Error('当前本地账号系统暂未启用邮件找回密码,请登录后在个人设置中修改密码,或联系管理员重置。')
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
const initialUser = session?.user ?? null
|
||||
if (initialUser) await fetchProfile(initialUser.id)
|
||||
user.value = initialUser
|
||||
try {
|
||||
const data = await api.auth.me()
|
||||
user.value = data.user
|
||||
profile.value = data.profile
|
||||
} 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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { api } from '@/lib/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export const useCloudsStore = defineStore('clouds', () => {
|
||||
@@ -10,12 +10,9 @@ export const useCloudsStore = defineStore('clouds', () => {
|
||||
async function fetchCloudTypes() {
|
||||
if (cloudTypes.value.length > 0) return
|
||||
loading.value = true
|
||||
const { data } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('*')
|
||||
.order('id')
|
||||
const { data } = await api.cloudTypes.list()
|
||||
if (data) {
|
||||
cloudTypes.value = data as CloudType[]
|
||||
cloudTypes.value = data
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { CloudType, UserCollection } from '@/types/database'
|
||||
|
||||
@@ -25,22 +25,6 @@ function getErrorMessage(error: unknown) {
|
||||
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', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -68,14 +52,8 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
|
||||
loadingCloudTypes.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('*')
|
||||
.order('id')
|
||||
|
||||
if (error) throw error
|
||||
|
||||
cloudTypes.value = (data || []) as CloudType[]
|
||||
const { data } = await api.cloudTypes.list()
|
||||
cloudTypes.value = data || []
|
||||
cloudTypesLoaded.value = true
|
||||
} finally {
|
||||
loadingCloudTypes.value = false
|
||||
@@ -98,36 +76,8 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
collectionError.value = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_collections')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('unlocked_at', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const collectionRows = (data || []) as UserCollection[]
|
||||
const firstCloudIds = collectionRows
|
||||
.map(item => item.first_cloud_id)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
let firstCloudMap = new Map<string, CollectionPreviewCloud>()
|
||||
|
||||
if (firstCloudIds.length) {
|
||||
const { data: firstCloudData, error: firstCloudError } = await supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,captured_at,created_at,location_name')
|
||||
.in('id', firstCloudIds)
|
||||
|
||||
if (firstCloudError) throw firstCloudError
|
||||
|
||||
firstCloudMap = toCollectionCloudMap(firstCloudData as Array<Record<string, unknown>> | null)
|
||||
}
|
||||
|
||||
myCollection.value = collectionRows.map(item => ({
|
||||
...item,
|
||||
firstCloud: item.first_cloud_id ? firstCloudMap.get(item.first_cloud_id) ?? null : null,
|
||||
}))
|
||||
const { data } = await api.collections.mine()
|
||||
myCollection.value = data
|
||||
collectionLoadedForUserId.value = userId
|
||||
} catch (error) {
|
||||
myCollection.value = []
|
||||
|
||||
+13
-106
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { api } from '@/lib/api'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
|
||||
export interface ProfileCloudItem {
|
||||
@@ -44,28 +44,6 @@ function toProfileCloud(row: Record<string, unknown>) {
|
||||
} 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', () => {
|
||||
const profilesById = ref<Record<string, Profile>>({})
|
||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||
@@ -102,32 +80,13 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
errorByKey.value[key] = ''
|
||||
|
||||
try {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
const [{ profile }, { data: cloudRows }] = await Promise.all([
|
||||
api.profiles.get(userId),
|
||||
api.profiles.clouds(userId, isOwnProfile),
|
||||
])
|
||||
|
||||
if (profileError) throw profileError
|
||||
profilesById.value[userId] = profile as Profile
|
||||
|
||||
let cloudsQuery = supabase
|
||||
.from('clouds')
|
||||
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
|
||||
.eq('user_id', userId)
|
||||
.order('captured_at', { ascending: false, nullsFirst: false })
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (!isOwnProfile) {
|
||||
cloudsQuery = cloudsQuery
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
}
|
||||
|
||||
const { data: cloudRows, error: cloudsError } = await cloudsQuery
|
||||
if (cloudsError) throw cloudsError
|
||||
|
||||
cloudsByKey.value[key] = ((cloudRows || []) as Array<Record<string, unknown>>).map(toProfileCloud)
|
||||
profilesById.value[userId] = profile
|
||||
cloudsByKey.value[key] = ((cloudRows || []) as unknown as Array<Record<string, unknown>>).map(toProfileCloud)
|
||||
} catch (error) {
|
||||
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
||||
cloudsByKey.value[key] = []
|
||||
@@ -178,76 +137,24 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
captured_at: string | null
|
||||
is_hidden: boolean
|
||||
}) {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.update(patch)
|
||||
.eq('id', cloudId)
|
||||
.eq('user_id', userId)
|
||||
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const updated = toProfileCloud(data as Record<string, unknown>)
|
||||
const { cloud } = await api.clouds.update(cloudId, patch)
|
||||
const updated = toProfileCloud(cloud as unknown as Record<string, unknown>)
|
||||
patchCachedCloud(userId, cloudId, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.update({ is_hidden: isHidden })
|
||||
.eq('id', cloudId)
|
||||
.eq('user_id', userId)
|
||||
.select('id')
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.length) {
|
||||
throw new Error('私密状态没有写入数据库,请检查 clouds 表的 UPDATE RLS policy。')
|
||||
}
|
||||
|
||||
await api.clouds.update(cloudId, { is_hidden: isHidden })
|
||||
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
|
||||
}
|
||||
|
||||
async function deleteClouds(userId: string, cloudIds: string[]) {
|
||||
if (!cloudIds.length) return 0
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.in('id', cloudIds)
|
||||
.select('id,image_url,thumbnail_url')
|
||||
|
||||
if (error) {
|
||||
if (getSupabaseErrorCode(error) === '23503') {
|
||||
throw new Error('这张照片仍被图鉴收藏记录引用,请先在数据库把 user_collections.first_cloud_id 外键改为 ON DELETE SET NULL。')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const deletedIds = (data || []).map(item => item.id as string)
|
||||
const results = await Promise.all(cloudIds.map(id => api.clouds.delete(id)))
|
||||
const deletedIds = results.flatMap(result => result.deleted)
|
||||
if (deletedIds.length !== cloudIds.length) {
|
||||
throw new Error('图片没有真正从数据库删除,请检查 clouds 表的 DELETE RLS policy。')
|
||||
}
|
||||
|
||||
const storagePaths = Array.from(new Set(
|
||||
(data || [])
|
||||
.flatMap(item => [
|
||||
getCloudStoragePath((item.image_url as string | null) ?? null),
|
||||
getCloudStoragePath((item.thumbnail_url as string | null) ?? null),
|
||||
])
|
||||
.filter((path): path is string => !!path),
|
||||
))
|
||||
|
||||
if (storagePaths.length) {
|
||||
const { error: storageError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.remove(storagePaths)
|
||||
|
||||
if (storageError) {
|
||||
throw new Error(`图片数据库记录已删除,但 Supabase Storage 文件清理失败:${storageError.message}`)
|
||||
}
|
||||
throw new Error('图片没有完全删除,请刷新后重试。')
|
||||
}
|
||||
|
||||
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 ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
@@ -172,88 +172,27 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
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() {
|
||||
const [
|
||||
userCount,
|
||||
imageCount,
|
||||
todayUploads,
|
||||
pendingCount,
|
||||
approvedCount,
|
||||
rejectedCount,
|
||||
hiddenCount,
|
||||
] = await Promise.all([
|
||||
countProfiles(),
|
||||
countClouds(),
|
||||
countClouds({ createdAfter: todayIsoStart() }),
|
||||
countClouds({ status: 'pending' }),
|
||||
countClouds({ status: 'approved' }),
|
||||
countClouds({ status: 'rejected' }),
|
||||
countClouds({ isHidden: true }),
|
||||
])
|
||||
|
||||
const { stats } = await api.admin.stats()
|
||||
dashboardStats.value = {
|
||||
users: userCount,
|
||||
images: imageCount,
|
||||
todayUploads,
|
||||
pending: pendingCount,
|
||||
approved: approvedCount,
|
||||
rejected: rejectedCount,
|
||||
hidden: hiddenCount,
|
||||
users: stats.users || 0,
|
||||
images: stats.images || 0,
|
||||
todayUploads: stats.todayUploads || 0,
|
||||
pending: stats.pending || 0,
|
||||
approved: stats.approved || 0,
|
||||
rejected: stats.rejected || 0,
|
||||
hidden: stats.hidden || 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id,username,avatar_url,role,is_disabled,created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (error) throw error
|
||||
users.value = (data || []) as Profile[]
|
||||
const { data } = await api.admin.users()
|
||||
users.value = data || []
|
||||
}
|
||||
|
||||
async function fetchImages() {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.select('id,user_id,cloud_type_id,custom_cloud_type,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,cloud_types(name,rarity),profiles(username)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(120)
|
||||
|
||||
if (error) throw error
|
||||
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
|
||||
const { data } = await api.admin.clouds()
|
||||
images.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toAdminCloud)
|
||||
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
|
||||
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
|
||||
}
|
||||
@@ -334,18 +273,9 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from('clouds')
|
||||
.update({ status })
|
||||
.select('id')
|
||||
|
||||
query = ids.length === 1 ? query.eq('id', ids[0]) : query.in('id', ids)
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) throw error
|
||||
if ((data || []).length !== ids.length) {
|
||||
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||
const { updated } = await api.admin.updateClouds(ids, { status })
|
||||
if (updated.length !== ids.length) {
|
||||
throw new Error('部分图片状态没有写入数据库。')
|
||||
}
|
||||
|
||||
patchImages(ids, { status })
|
||||
@@ -369,15 +299,9 @@ async function updateImageVisibility(ids: string[], isHidden: boolean) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.update({ is_hidden: isHidden })
|
||||
.in('id', ids)
|
||||
.select('id')
|
||||
|
||||
if (error) throw error
|
||||
if ((data || []).length !== ids.length) {
|
||||
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||
const { updated } = await api.admin.updateClouds(ids, { is_hidden: isHidden })
|
||||
if (updated.length !== ids.length) {
|
||||
throw new Error('图片可见性没有写入数据库。')
|
||||
}
|
||||
|
||||
patchImages(ids, { is_hidden: isHidden })
|
||||
@@ -455,18 +379,8 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ role })
|
||||
.eq('id', user.id)
|
||||
.select('id,role')
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.length) {
|
||||
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||
}
|
||||
|
||||
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
|
||||
const { profile } = await api.admin.updateUser(user.id, { role })
|
||||
users.value = users.value.map(item => (item.id === user.id ? profile : item))
|
||||
message.success('用户角色已更新')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '用户角色更新失败')
|
||||
@@ -488,18 +402,8 @@ async function toggleUserDisabled(user: Profile) {
|
||||
|
||||
try {
|
||||
const nextDisabled = !user.is_disabled
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ is_disabled: nextDisabled })
|
||||
.eq('id', user.id)
|
||||
.select('id,is_disabled')
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.length) {
|
||||
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
|
||||
}
|
||||
|
||||
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
|
||||
const { profile } = await api.admin.updateUser(user.id, { is_disabled: nextDisabled })
|
||||
users.value = users.value.map(item => (item.id === user.id ? profile : item))
|
||||
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
||||
} catch (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>
|
||||
<h1 class="mt-3 text-4xl font-bold text-slate-950">管理后台</h1>
|
||||
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都直接落到 Supabase。
|
||||
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都通过本地后端 API 校验后落库。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,85 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
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)
|
||||
})
|
||||
import { NButton, NCard, NResult } from 'naive-ui'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<template v-if="state === 'success'">
|
||||
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
|
||||
<NResult
|
||||
status="info"
|
||||
title="本地账号无需邮箱确认"
|
||||
description="OpenCloud 当前使用本地 PostgreSQL 账号系统,注册后可以直接登录。"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<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">立即登录</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>
|
||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,18 +81,18 @@ onUnmounted(() => {
|
||||
<span class="block text-sky-700">OpenCloud 账号</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
输入注册邮箱后,我们会发送一封密码重置邮件。打开邮件中的链接,即可进入新的密码设置页面。
|
||||
当前本地账号系统暂未启用邮件找回密码。已登录用户可以在个人设置中修改密码;无法登录时请联系管理员处理。
|
||||
</p>
|
||||
<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="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-1 text-sm text-slate-500">使用注册邮箱请求重置链接</div>
|
||||
<div class="mt-2 text-lg font-bold text-slate-900">联系管理员</div>
|
||||
<div class="mt-1 text-sm text-slate-500">确认账号归属后处理</div>
|
||||
</div>
|
||||
<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="mt-2 text-lg font-bold text-slate-900">设置新密码</div>
|
||||
<div class="mt-1 text-sm text-slate-500">在专用页面完成新密码更新</div>
|
||||
<div class="mt-2 text-lg font-bold text-slate-900">个人设置</div>
|
||||
<div class="mt-1 text-sm text-slate-500">登录后可自行修改密码</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -101,8 +101,8 @@ onUnmounted(() => {
|
||||
<NResult
|
||||
v-if="submitted"
|
||||
status="success"
|
||||
title="重置邮件已发送"
|
||||
description="如果该邮箱已注册,请查收邮件中的重置链接。"
|
||||
title="暂未启用邮件重置"
|
||||
description="当前版本不会发送密码重置邮件,请联系管理员或登录后在个人设置中修改。"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
@@ -132,7 +132,7 @@ onUnmounted(() => {
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-slate-500">输入你的注册邮箱,我们会发送一封密码重置邮件。</p>
|
||||
<p class="mt-2 text-sm text-slate-500">当前版本暂未启用邮件发送;提交后会显示处理说明。</p>
|
||||
</div>
|
||||
|
||||
<NForm @submit.prevent="handleSendResetEmail">
|
||||
|
||||
@@ -77,13 +77,13 @@ async function handleRegister() {
|
||||
<NResult
|
||||
v-if="emailSent"
|
||||
status="success"
|
||||
title="确认你的邮箱"
|
||||
description="确认邮件已经发送,请查收并点击链接完成注册。"
|
||||
title="注册成功"
|
||||
description="账号已经创建,本地账号系统无需邮箱确认,可以直接登录。"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">
|
||||
目标邮箱:
|
||||
注册邮箱:
|
||||
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||
</p>
|
||||
<RouterLink to="/login" class="no-underline">
|
||||
|
||||
@@ -1,118 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
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)
|
||||
})
|
||||
import { NButton, NCard, NResult } from 'naive-ui'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -120,88 +7,21 @@ onUnmounted(() => {
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NResult
|
||||
v-if="state === 'success'"
|
||||
status="success"
|
||||
title="密码已重置"
|
||||
description="你已使用新密码完成更新,正在返回地图页。"
|
||||
status="warning"
|
||||
title="邮件重置暂未启用"
|
||||
description="迁移到本地账号系统后,当前版本暂不发送密码重置邮件。已登录用户可在个人资料设置中修改密码。"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转地图页面...</p>
|
||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/')">进入地图</NButton>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<RouterLink to="/login" class="no-underline">
|
||||
<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>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
import type { CloudType } from '@/types/database'
|
||||
@@ -82,32 +82,9 @@ function openGalleryDetail(item: CloudGalleryItem) {
|
||||
}
|
||||
|
||||
async function loadGallery(typeId: number) {
|
||||
const galleryQuery = supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,profiles(username)')
|
||||
.eq('cloud_type_id', typeId)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
.order('captured_at', { ascending: false, nullsFirst: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(24)
|
||||
const { data: galleryData, count } = await api.cloudTypes.gallery(typeId)
|
||||
|
||||
const countQuery = supabase
|
||||
.from('clouds')
|
||||
.select('*', { head: true, count: 'exact' })
|
||||
.eq('cloud_type_id', typeId)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
|
||||
const [{ data: galleryData, error: galleryError }, { count, error: countError }] = await Promise.all([
|
||||
galleryQuery,
|
||||
countQuery,
|
||||
])
|
||||
|
||||
if (galleryError) throw galleryError
|
||||
if (countError) throw countError
|
||||
|
||||
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => {
|
||||
gallery.value = ((galleryData || []) as unknown as Array<Record<string, unknown>>).map(row => {
|
||||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||
const profile = profiles[0] as Record<string, unknown> | undefined
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
@@ -87,34 +87,6 @@ function formatCoordinate(value: number | null) {
|
||||
const normalizedSearch = computed(() => searchQuery.value.trim())
|
||||
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
||||
|
||||
function sanitizeSearchTerm(term: string) {
|
||||
return term.replace(/[(),*%]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function getMatchedCloudTypeIds(term: string) {
|
||||
const lowerTerm = term.toLocaleLowerCase('zh-CN')
|
||||
return cloudsStore.cloudTypes
|
||||
.filter(type => {
|
||||
return type.name.toLocaleLowerCase('zh-CN').includes(lowerTerm) ||
|
||||
type.name_en.toLocaleLowerCase('zh-CN').includes(lowerTerm)
|
||||
})
|
||||
.map(type => type.id)
|
||||
}
|
||||
|
||||
async function fetchUserIdsBySearch(term: string) {
|
||||
const sanitized = sanitizeSearchTerm(term)
|
||||
if (!sanitized) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.ilike('username', `%${sanitized}%`)
|
||||
.limit(100)
|
||||
|
||||
if (error) throw error
|
||||
return ((data || []) as Array<{ id: string }>).map(profile => profile.id)
|
||||
}
|
||||
|
||||
function toGalleryCloud(row: Record<string, unknown>) {
|
||||
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
||||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||
@@ -142,48 +114,12 @@ function toGalleryCloud(row: Record<string, unknown>) {
|
||||
} satisfies GalleryCloud
|
||||
}
|
||||
|
||||
async function resolveSearchFilters() {
|
||||
const search = normalizedSearch.value
|
||||
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
|
||||
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
|
||||
|
||||
if (isUserSearch.value && !usernameTerm) return null
|
||||
if (search && !isUserSearch.value && !cloudTypeTerm) return null
|
||||
|
||||
let userIds: string[] = []
|
||||
if (usernameTerm) {
|
||||
userIds = await fetchUserIdsBySearch(usernameTerm)
|
||||
if (userIds.length === 0) return null
|
||||
}
|
||||
|
||||
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
|
||||
|
||||
return { usernameTerm, cloudTypeTerm, userIds, matchedCloudTypeIds }
|
||||
}
|
||||
|
||||
const FULL_SELECT = 'id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)'
|
||||
|
||||
function buildFilteredQuery(selectStr: string, options?: { count?: 'exact'; head?: boolean }) {
|
||||
let query = supabase
|
||||
.from('clouds')
|
||||
.select(selectStr, options as any)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
|
||||
if (selectedTypeId.value !== 'all') {
|
||||
query = query.eq('cloud_type_id', selectedTypeId.value)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
async function loadPage(page: number) {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const filters = await resolveSearchFilters()
|
||||
if (filters === null) {
|
||||
if (isUserSearch.value && !normalizedSearch.value.slice(1).trim()) {
|
||||
galleryItems.value = []
|
||||
totalCount.value = 0
|
||||
currentPage.value = 1
|
||||
@@ -191,29 +127,12 @@ async function loadPage(page: number) {
|
||||
return
|
||||
}
|
||||
|
||||
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true })
|
||||
let dataQuery = buildFilteredQuery(FULL_SELECT)
|
||||
.order('created_at', { ascending: false })
|
||||
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1)
|
||||
|
||||
if (filters.usernameTerm) {
|
||||
countQuery = countQuery.in('user_id', filters.userIds)
|
||||
dataQuery = dataQuery.in('user_id', filters.userIds)
|
||||
} else if (filters.cloudTypeTerm) {
|
||||
if (filters.matchedCloudTypeIds.length) {
|
||||
const orFilter = `cloud_type_id.in.(${filters.matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${filters.cloudTypeTerm}*`
|
||||
countQuery = countQuery.or(orFilter)
|
||||
dataQuery = dataQuery.or(orFilter)
|
||||
} else {
|
||||
countQuery = countQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
|
||||
dataQuery = dataQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
|
||||
}
|
||||
}
|
||||
|
||||
const [{ count }, { data, error }] = await Promise.all([countQuery, dataQuery])
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const { data, count } = await api.clouds.list({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
typeId: selectedTypeId.value,
|
||||
search: normalizedSearch.value,
|
||||
})
|
||||
totalCount.value = count ?? 0
|
||||
galleryItems.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toGalleryCloud)
|
||||
currentPage.value = page
|
||||
|
||||
+10
-17
@@ -3,7 +3,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { api } from '@/lib/api'
|
||||
import { loadAMap } from '@/lib/amap'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
.not('latitude', 'is', null)
|
||||
.not('longitude', 'is', null)
|
||||
.gte(field, start.toISOString())
|
||||
.lt(field, end.toISOString())
|
||||
.order(field, { ascending: true })
|
||||
.limit(1000)
|
||||
|
||||
if (error) {
|
||||
statusText.value = `查询失败: ${error.message}`
|
||||
try {
|
||||
const { data } = await api.clouds.map({
|
||||
field,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
})
|
||||
return ((data || []) as unknown as Array<Record<string, unknown>>).map(toCloudMarker)
|
||||
} catch (error) {
|
||||
statusText.value = `查询失败: ${error instanceof Error ? error.message : '地图数据加载失败'}`
|
||||
return []
|
||||
}
|
||||
|
||||
return ((data || []) as Array<Record<string, unknown>>).map(toCloudMarker)
|
||||
}
|
||||
|
||||
async function loadRealtimeClouds() {
|
||||
|
||||
@@ -57,6 +57,12 @@ function seoFilesPlugin() {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss(), seoFilesPlugin()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
'/uploads': 'http://localhost:3001',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user