Migrate from Supabase to local Express API

This commit is contained in:
2026-06-14 16:57:50 +08:00
parent 217b81c506
commit 6359231b99
24 changed files with 3208 additions and 1192 deletions
+3
View File
@@ -24,3 +24,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Lock
package-lock.json
+51 -3
View File
@@ -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`
+1929 -91
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -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
View File
@@ -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}`)
})
+104
View File
@@ -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
View File
@@ -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
if (
insertedCloud &&
typeof insertedCloud.cloud_type_id === 'number' &&
!unlockedTypeIds.has(insertedCloud.cloud_type_id)
) {
try {
const { data: collectionRow, error: collectionError } = await supabase
.from('user_collections')
.insert({
user_id: userId,
cloud_type_id: insertedCloud.cloud_type_id,
first_cloud_id: insertedCloud.id,
})
.select('cloud_type_id,unlocked_at')
.single()
if (collectionError) {
const duplicate = collectionError.code === '23505'
if (!duplicate) throw collectionError
} else if (collectionRow) {
unlockedTypeIds.add(collectionRow.cloud_type_id)
newlyUnlockedRows.push({
cloudTypeId: collectionRow.cloud_type_id,
unlockedAt: collectionRow.unlocked_at,
})
}
} catch {
// Ignore collection sync failures so uploads can still complete.
}
const form = new FormData()
form.append('image', item.file)
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))
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: [] }
+13 -13
View File
@@ -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',
plugins: [
'AMap.Scale',
'AMap.ToolBar',
'AMap.ControlBar',
'AMap.Geolocation',
'AMap.Marker',
'AMap.InfoWindow',
],
})
return AMapLoader.load({
key: import.meta.env.VITE_AMAP_KEY,
version: "2.0",
plugins: [
"AMap.Scale",
"AMap.ToolBar",
"AMap.ControlBar",
"AMap.Geolocation",
"AMap.Marker",
"AMap.InfoWindow",
],
});
}
+155
View File
@@ -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,
}),
},
}
-152
View File
@@ -1,152 +0,0 @@
import {
createClient,
type EmailOtpType,
type Session,
type SupabaseClient,
} from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
const knownEmailOtpTypes = new Set<EmailOtpType>([
'signup',
'invite',
'magiclink',
'recovery',
'email_change',
'email',
])
type AuthCallbackParams = {
accessToken: string | null
code: string | null
error: string | null
errorCode: string | null
errorDescription: string | null
refreshToken: string | null
tokenHash: string | null
type: EmailOtpType | null
}
type ResolveEmailAuthCallbackOptions = {
allowedTypes: EmailOtpType[]
client: SupabaseClient
}
type ResolveEmailAuthCallbackResult =
| {
ok: true
session: Session | null
type: EmailOtpType | null
}
| {
ok: false
error: string
}
function parseEmailOtpType(value: string | null): EmailOtpType | null {
if (!value || !knownEmailOtpTypes.has(value as EmailOtpType)) {
return null
}
return value as EmailOtpType
}
export function readAuthCallbackParams(): AuthCallbackParams {
const search = new URLSearchParams(window.location.search)
const hash = new URLSearchParams(window.location.hash.slice(1))
return {
code: search.get('code'),
tokenHash: search.get('token_hash') ?? hash.get('token_hash'),
accessToken: hash.get('access_token'),
refreshToken: hash.get('refresh_token'),
type: parseEmailOtpType(search.get('type') ?? hash.get('type')),
error: search.get('error') ?? hash.get('error'),
errorCode: search.get('error_code') ?? hash.get('error_code'),
errorDescription: search.get('error_description') ?? hash.get('error_description'),
}
}
export function clearAuthCallbackUrl() {
window.history.replaceState(null, '', window.location.pathname)
}
export function createDetachedAuthClient() {
return createClient(supabaseUrl, supabaseKey, {
auth: {
autoRefreshToken: false,
detectSessionInUrl: false,
persistSession: false,
},
})
}
export async function resolveEmailAuthCallback(
options: ResolveEmailAuthCallbackOptions,
): Promise<ResolveEmailAuthCallbackResult> {
const { client, allowedTypes } = options
const params = readAuthCallbackParams()
const allowedTypeSet = new Set(allowedTypes)
if (params.error || params.errorCode || params.errorDescription) {
return {
ok: false,
error: params.errorDescription || '链接无效或已过期。',
}
}
try {
let session: Session | null = null
if (params.tokenHash && params.type && allowedTypeSet.has(params.type)) {
const { data, error } = await client.auth.verifyOtp({
token_hash: params.tokenHash,
type: params.type,
})
if (error) throw error
session = data.session
} else if (params.code) {
const { data, error } = await client.auth.exchangeCodeForSession(params.code)
if (error) throw error
session = data.session
} else if (
params.accessToken
&& params.refreshToken
&& params.type
&& allowedTypeSet.has(params.type)
) {
const { data, error } = await client.auth.setSession({
access_token: params.accessToken,
refresh_token: params.refreshToken,
})
if (error) throw error
session = data.session
} else {
return {
ok: false,
error: '链接无效或已过期。',
}
}
if (!session) {
const { data, error } = await client.auth.getSession()
if (error) throw error
session = data.session
}
clearAuthCallbackUrl()
return {
ok: true,
session,
type: params.type,
}
} catch (error) {
const message = error instanceof Error ? error.message : '链接无效或已过期。'
return {
ok: false,
error: message,
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
if (!supabaseUrl || !supabaseKey) {
throw new Error('Missing Supabase environment variables')
}
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
// Email confirmation and password recovery are handled by route components.
detectSessionInUrl: false,
},
})
+25 -98
View File
@@ -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
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
}
})
try {
const data = await api.auth.me()
user.value = data.user
profile.value = data.profile
} finally {
loading.value = false
}
}
return {
+3 -6
View File
@@ -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
}
+5 -55
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+8 -71
View File
@@ -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>
<RouterLink to="/login" class="no-underline">
<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>
+8 -8
View File
@@ -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">
+3 -3
View File
@@ -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">
+11 -191
View File
@@ -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>
+3 -26
View File
@@ -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
+8 -89
View File
@@ -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
View File
@@ -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() {
+6
View File
@@ -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)),