后端迁移到fastapi (#3)

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-18 20:49:09 +08:00
parent 217b81c506
commit 51743645e2
71 changed files with 845 additions and 3580 deletions
+7 -5
View File
@@ -13,7 +13,7 @@ const isMapRoute = computed(() => route.name === 'map')
const headerHidden = ref(false)
const headerPinnedOpen = ref(false)
const accountCardOpen = ref(false)
const activeNavClass = 'bg-teal-100 text-teal-800 ring-1 ring-teal-200'
const activeNavClass = 'bg-teal-50 text-teal-800 ring-1 ring-teal-100'
const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
@@ -193,6 +193,7 @@ async function handleLogout() {
画廊
</RouterLink>
<RouterLink
v-if="authStore.isLoggedIn"
to="/encyclopedia"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
@@ -254,10 +255,10 @@ async function handleLogout() {
</div>
</div>
<div class="mt-3 flex items-center gap-2 text-xs text-slate-500">
<span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
<!-- <span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
{{ authStore.isAdmin ? '管理员' : '观测者' }}
</span>
<span>OpenCloud 账号</span>
<span>OpenCloud 账号</span> -->
</div>
</div>
@@ -312,7 +313,7 @@ async function handleLogout() {
>
<span
class="inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3 text-sm font-medium text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] md:h-10 md:px-4"
:class="route.name === 'login' ? 'bg-teal-50 ring-1 ring-teal-200 text-teal-800' : ''"
:class="route.name === 'login' ? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800' : ''"
>
登录
</span>
@@ -323,7 +324,7 @@ async function handleLogout() {
>
<span
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
:class="route.name === 'register' ? 'ring-1 ring-sky-300' : ''"
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
>
注册
</span>
@@ -356,6 +357,7 @@ async function handleLogout() {
画廊
</RouterLink>
<RouterLink
v-if="authStore.isLoggedIn"
to="/encyclopedia"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
+80 -285
View File
@@ -1,6 +1,8 @@
import { ref } from 'vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useProfileStore } from '@/stores/profile'
import type { CloudCreateResponse } from '@/types/api'
import type { CloudType } from '@/types/database'
export interface UploadItem {
@@ -32,8 +34,6 @@ export interface UploadResult {
}
let nextId = 0
const THUMBNAIL_MAX_EDGE = 640
const THUMBNAIL_QUALITY = 0.72
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
@@ -44,84 +44,6 @@ function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
})
}
function loadImageElement(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file)
const image = new Image()
image.onload = () => {
URL.revokeObjectURL(objectUrl)
resolve(image)
}
image.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error('图片读取失败'))
}
image.src = objectUrl
})
}
function getResizedDimensions(width: number, height: number, maxEdge: number) {
const largestEdge = Math.max(width, height)
if (largestEdge <= maxEdge) {
return { width, height }
}
const scale = maxEdge / largestEdge
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
}
}
function fileBaseName(name: string) {
const idx = name.lastIndexOf('.')
return idx === -1 ? name : name.slice(0, idx)
}
async function renderJpegFile(
image: HTMLImageElement,
sourceFile: File,
maxEdge: number,
quality: number,
suffix = '',
) {
const { width, height } = getResizedDimensions(image.naturalWidth || image.width, image.naturalHeight || image.height, maxEdge)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) {
throw new Error('图片压缩失败')
}
context.drawImage(image, 0, 0, width, height)
const blob = await new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve, 'image/jpeg', quality)
})
if (!blob) {
throw new Error('图片压缩失败')
}
return new File([blob], `${fileBaseName(sourceFile.name)}${suffix}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
})
}
async function createUploadAssets(file: File) {
const image = await loadImageElement(file)
return {
thumbnailFile: await renderJpegFile(image, file, THUMBNAIL_MAX_EDGE, THUMBNAIL_QUALITY, '-thumb'),
}
}
function extractExifDate(buffer: ArrayBuffer): string | null {
const view = new DataView(buffer)
if (view.getUint16(0, false) !== 0xffd8) return null
@@ -146,23 +68,20 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
const tiffStart = exifStart + 6
const isLE = view.getUint16(tiffStart, false) === 0x4949
const ifd0Offset = view.getUint32(tiffStart + 4, isLE)
const ifdStart = tiffStart + ifd0Offset
const ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE)
const numEntries = view.getUint16(ifdStart, isLE)
for (let i = 0; i < numEntries; i++) {
const entryStart = ifdStart + 2 + i * 12
if (entryStart + 12 > buffer.byteLength) break
const tag = view.getUint16(entryStart, isLE)
if (tag === 0x9003) {
const dataOffset = view.getUint32(entryStart + 8, isLE)
const dateStr = Array.from({ length: 20 }, (_, j) =>
String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)),
).join('')
const iso = dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')
const d = new Date(iso)
if (!isNaN(d.getTime())) return d.toISOString()
}
if (view.getUint16(entryStart, isLE) !== 0x9003) continue
const dataOffset = view.getUint32(entryStart + 8, isLE)
const dateStr = Array.from({ length: 20 }, (_, j) =>
String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)),
).join('')
const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3'))
if (!isNaN(date.getTime())) return date.toISOString()
}
}
offset += length - 2
@@ -170,51 +89,12 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
return null
}
async function fetchUnlockedTypeIds(userId: string) {
const { data, error } = await supabase
.from('user_collections')
.select('cloud_type_id')
.eq('user_id', userId)
if (error) throw error
return new Set(
(data || [])
.map(row => row.cloud_type_id)
.filter((cloudTypeId): cloudTypeId is number => typeof cloudTypeId === 'number'),
)
}
async function fetchBadgeDetails(unlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }>) {
const ids = unlockedRows.map(item => item.cloudTypeId)
const { data, error } = await supabase
.from('cloud_types')
.select('id,name,name_en,rarity')
.in('id', ids)
if (error) throw error
const typeMap = new Map(
((data || []) as Array<Pick<CloudType, 'id' | 'name' | 'name_en' | 'rarity'>>).map(item => [item.id, item]),
)
return unlockedRows
.map(item => {
const cloudType = typeMap.get(item.cloudTypeId)
if (!cloudType) return null
return {
cloudTypeId: cloudType.id,
cloudName: cloudType.name,
cloudNameEn: cloudType.name_en,
rarity: cloudType.rarity,
unlockedAt: item.unlockedAt,
} satisfies UnlockedBadge
})
.filter((item): item is UnlockedBadge => item !== null)
function appendOptional(form: FormData, key: string, value: string | number | null) {
if (value !== null && value !== '') form.append(key, String(value))
}
export function useUpload() {
const authStore = useAuthStore()
const profileStore = useProfileStore()
const items = ref<UploadItem[]>([])
const uploading = ref(false)
@@ -223,15 +103,14 @@ export function useUpload() {
const totalItems = ref(0)
async function addFiles(files: File[]) {
const imageFiles = files.filter(f => f.type.startsWith('image/'))
const imageFiles = files.filter(file => file.type.startsWith('image/'))
for (const file of imageFiles) {
let capturedAt = new Date().toISOString()
try {
const buffer = await readFileAsArrayBuffer(file)
const exifDate = extractExifDate(buffer)
const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
if (exifDate) capturedAt = exifDate
} catch {
// fallback to now
// 无 EXIF 时使用当前时间。
}
items.value.push({
@@ -252,182 +131,86 @@ export function useUpload() {
}
function removeItem(id: string) {
const idx = items.value.findIndex(i => i.id === id)
if (idx !== -1) {
URL.revokeObjectURL(items.value[idx].preview)
items.value.splice(idx, 1)
}
const index = items.value.findIndex(item => item.id === id)
if (index === -1) return
URL.revokeObjectURL(items.value[index].preview)
items.value.splice(index, 1)
}
function clearAll() {
for (const item of items.value) {
URL.revokeObjectURL(item.preview)
}
items.value.forEach(item => URL.revokeObjectURL(item.preview))
items.value = []
}
function validateItem(item: UploadItem): boolean {
function validateItem(item: UploadItem) {
const errors: Record<string, string> = {}
if (!item.cloudCategoryId) {
errors.cloudCategory = '请选择类别'
}
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
errors.cloudCategory = '请输入自定义云型名称'
}
if (!item.capturedAt) {
errors.capturedAt = '请选择拍摄时间'
}
if (!item.capturedAt) errors.capturedAt = '请选择拍摄时间'
const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude)
const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude)
if (hasLat && !hasLng) {
errors.longitude = '经纬度必须同时填写'
}
if (hasLng && !hasLat) {
errors.latitude = '经纬度必须同时填写'
}
if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写'
if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写'
item.errors = errors
return Object.keys(errors).length === 0
}
function validateAll(): boolean {
let allValid = true
for (const item of items.value) {
if (!validateItem(item)) {
allValid = false
}
}
return allValid
}
function blurCoordinate(value: number): number {
return Math.round(value * 100) / 100
function validateAll() {
return items.value.map(validateItem).every(Boolean)
}
async function uploadAll(): Promise<UploadResult> {
if (!validateAll()) {
return { ok: false, unlockedBadges: [] }
}
if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
uploading.value = true
totalItems.value = items.value.length
currentItemIndex.value = 0
overallProgress.value = 0
const userId = (await supabase.auth.getUser()).data.user?.id
if (!userId) {
uploading.value = false
return { ok: false, unlockedBadges: [] }
}
const badges = new Map<number, UnlockedBadge>()
try {
let unlockedTypeIds = new Set<number>()
try {
unlockedTypeIds = await fetchUnlockedTypeIds(userId)
} catch {
unlockedTypeIds = new Set<number>()
}
for (let index = 0; index < items.value.length; index++) {
const item = items.value[index]
currentItemIndex.value = index + 1
overallProgress.value = Math.round(index / items.value.length * 100)
const newlyUnlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }> = []
for (let i = 0; i < items.value.length; i++) {
const item = items.value[i]
currentItemIndex.value = i + 1
overallProgress.value = Math.round(i / items.value.length * 100)
const basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const ext = item.file.name.split('.').pop() || 'jpg'
const imagePath = `${basePath}.${ext}`
const thumbnailPath = `${basePath}-thumb.jpg`
const { thumbnailFile } = await createUploadAssets(item.file)
const { error: imgError } = await supabase.storage
.from('clouds')
.upload(imagePath, item.file, {
upsert: false,
contentType: item.file.type,
})
if (imgError) throw imgError
const { error: thumbError } = await supabase.storage
.from('clouds')
.upload(thumbnailPath, thumbnailFile, {
upsert: false,
contentType: thumbnailFile.type,
})
if (thumbError) throw thumbError
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
const { data: { publicUrl: thumbnailUrl } } = supabase.storage.from('clouds').getPublicUrl(thumbnailPath)
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
const { data: insertedCloud, error: dbError } = await supabase
.from('clouds')
.insert({
user_id: userId,
cloud_type_id: item.cloudCategoryId === 'other' ? null : item.cloudCategoryId,
custom_cloud_type: item.cloudCategoryId === 'other' ? (item.customCloudType.trim() || null) : null,
image_url: imageUrl,
thumbnail_url: thumbnailUrl,
latitude,
longitude,
location_name: item.locationName || null,
description: item.description.trim() || null,
captured_at: item.capturedAt,
status: 'pending',
is_hidden: item.isHidden,
})
.select('id,cloud_type_id')
.single()
if (dbError) throw dbError
if (
insertedCloud &&
typeof insertedCloud.cloud_type_id === 'number' &&
!unlockedTypeIds.has(insertedCloud.cloud_type_id)
) {
try {
const { data: collectionRow, error: collectionError } = await supabase
.from('user_collections')
.insert({
user_id: userId,
cloud_type_id: insertedCloud.cloud_type_id,
first_cloud_id: insertedCloud.id,
})
.select('cloud_type_id,unlocked_at')
.single()
if (collectionError) {
const duplicate = collectionError.code === '23505'
if (!duplicate) throw collectionError
} else if (collectionRow) {
unlockedTypeIds.add(collectionRow.cloud_type_id)
newlyUnlockedRows.push({
cloudTypeId: collectionRow.cloud_type_id,
unlockedAt: collectionRow.unlocked_at,
})
}
} catch {
// Ignore collection sync failures so uploads can still complete.
}
const form = new FormData()
form.append('image', item.file)
if (item.cloudCategoryId === 'other') {
form.append('custom_cloud_type', item.customCloudType.trim())
} else {
form.append('cloud_type_id', String(item.cloudCategoryId))
}
appendOptional(form, 'latitude', item.latitude)
appendOptional(form, 'longitude', item.longitude)
appendOptional(form, 'location_name', item.locationName.trim())
appendOptional(form, 'description', item.description.trim())
appendOptional(form, 'captured_at', item.capturedAt)
form.append('is_hidden', String(item.isHidden))
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
const result = await apiRequest<CloudCreateResponse>('/clouds', {
method: 'POST',
body: form,
})
if (result.unlocked_badge) {
const badge = result.unlocked_badge
badges.set(badge.cloud_type_id, {
cloudTypeId: badge.cloud_type_id,
cloudName: badge.cloud_name,
cloudNameEn: badge.cloud_name_en,
rarity: badge.rarity,
unlockedAt: badge.unlocked_at,
})
}
overallProgress.value = Math.round((index + 1) / items.value.length * 100)
}
for (const item of items.value) {
URL.revokeObjectURL(item.preview)
}
items.value = []
profileStore.invalidateUser(userId)
return {
ok: true,
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
}
clearAll()
profileStore.invalidateUser(authStore.user.id)
return { ok: true, unlockedBadges: [...badges.values()] }
} catch {
return { ok: false, unlockedBadges: [] }
} finally {
@@ -435,5 +218,17 @@ export function useUpload() {
}
}
return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateItem, validateAll, uploadAll }
return {
items,
uploading,
overallProgress,
currentItemIndex,
totalItems,
addFiles,
removeItem,
clearAll,
validateItem,
validateAll,
uploadAll,
}
}
+134
View File
@@ -0,0 +1,134 @@
import type { AuthResponse } from '@/types/api'
const DEFAULT_API_URL = 'http://localhost:8000/api/v1'
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated'
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
let accessToken: string | null = null
let refreshPromise: Promise<AuthResponse> | null = null
export class ApiError extends Error {
status: number
detail: unknown
constructor(message: string, status: number, detail?: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.detail = detail
}
}
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: BodyInit | Record<string, unknown> | null
auth?: boolean
retry?: boolean
}
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
return !!body
&& typeof body === 'object'
&& !(body instanceof FormData)
&& !(body instanceof Blob)
&& !(body instanceof URLSearchParams)
&& !(body instanceof ArrayBuffer)
}
function detailToMessage(detail: unknown, fallback: string) {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const messages = detail
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
.filter(Boolean)
if (messages.length) return messages.join(';')
}
return fallback
}
async function toApiError(response: Response) {
let detail: unknown
try {
const payload = await response.json() as { detail?: unknown }
detail = payload.detail
} catch {
detail = undefined
}
return new ApiError(
detailToMessage(detail, `请求失败(${response.status})`),
response.status,
detail,
)
}
export function setAccessToken(token: string | null) {
accessToken = token
}
export function clearAccessToken() {
accessToken = null
}
async function request<T>(path: string, options: ApiRequestOptions = {}) {
const { body, auth = true, retry = true, ...requestOptions } = options
const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
if (isJsonBody(body)) {
headers.set('Content-Type', 'application/json')
requestBody = JSON.stringify(body)
}
if (auth && accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
const response = await fetch(`${API_URL}${path}`, {
...requestOptions,
body: requestBody,
headers,
credentials: 'include',
})
if (response.status === 401 && auth && accessToken && retry) {
try {
const authResponse = await refreshSession()
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
return request<T>(path, { ...options, retry: false })
} catch {
clearAccessToken()
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
}
}
if (!response.ok) throw await toApiError(response)
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
return request<T>(path, options)
}
export function refreshSession() {
if (!refreshPromise) {
refreshPromise = (async () => {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok) throw await toApiError(response)
const authResponse = await response.json() as AuthResponse
setAccessToken(authResponse.access_token)
return authResponse
})().finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
-152
View File
@@ -1,152 +0,0 @@
import {
createClient,
type EmailOtpType,
type Session,
type SupabaseClient,
} from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
const knownEmailOtpTypes = new Set<EmailOtpType>([
'signup',
'invite',
'magiclink',
'recovery',
'email_change',
'email',
])
type AuthCallbackParams = {
accessToken: string | null
code: string | null
error: string | null
errorCode: string | null
errorDescription: string | null
refreshToken: string | null
tokenHash: string | null
type: EmailOtpType | null
}
type ResolveEmailAuthCallbackOptions = {
allowedTypes: EmailOtpType[]
client: SupabaseClient
}
type ResolveEmailAuthCallbackResult =
| {
ok: true
session: Session | null
type: EmailOtpType | null
}
| {
ok: false
error: string
}
function parseEmailOtpType(value: string | null): EmailOtpType | null {
if (!value || !knownEmailOtpTypes.has(value as EmailOtpType)) {
return null
}
return value as EmailOtpType
}
export function readAuthCallbackParams(): AuthCallbackParams {
const search = new URLSearchParams(window.location.search)
const hash = new URLSearchParams(window.location.hash.slice(1))
return {
code: search.get('code'),
tokenHash: search.get('token_hash') ?? hash.get('token_hash'),
accessToken: hash.get('access_token'),
refreshToken: hash.get('refresh_token'),
type: parseEmailOtpType(search.get('type') ?? hash.get('type')),
error: search.get('error') ?? hash.get('error'),
errorCode: search.get('error_code') ?? hash.get('error_code'),
errorDescription: search.get('error_description') ?? hash.get('error_description'),
}
}
export function clearAuthCallbackUrl() {
window.history.replaceState(null, '', window.location.pathname)
}
export function createDetachedAuthClient() {
return createClient(supabaseUrl, supabaseKey, {
auth: {
autoRefreshToken: false,
detectSessionInUrl: false,
persistSession: false,
},
})
}
export async function resolveEmailAuthCallback(
options: ResolveEmailAuthCallbackOptions,
): Promise<ResolveEmailAuthCallbackResult> {
const { client, allowedTypes } = options
const params = readAuthCallbackParams()
const allowedTypeSet = new Set(allowedTypes)
if (params.error || params.errorCode || params.errorDescription) {
return {
ok: false,
error: params.errorDescription || '链接无效或已过期。',
}
}
try {
let session: Session | null = null
if (params.tokenHash && params.type && allowedTypeSet.has(params.type)) {
const { data, error } = await client.auth.verifyOtp({
token_hash: params.tokenHash,
type: params.type,
})
if (error) throw error
session = data.session
} else if (params.code) {
const { data, error } = await client.auth.exchangeCodeForSession(params.code)
if (error) throw error
session = data.session
} else if (
params.accessToken
&& params.refreshToken
&& params.type
&& allowedTypeSet.has(params.type)
) {
const { data, error } = await client.auth.setSession({
access_token: params.accessToken,
refresh_token: params.refreshToken,
})
if (error) throw error
session = data.session
} else {
return {
ok: false,
error: '链接无效或已过期。',
}
}
if (!session) {
const { data, error } = await client.auth.getSession()
if (error) throw error
session = data.session
}
clearAuthCallbackUrl()
return {
ok: true,
session,
type: params.type,
}
} catch (error) {
const message = error instanceof Error ? error.message : '链接无效或已过期。'
return {
ok: false,
error: message,
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
if (!supabaseUrl || !supabaseKey) {
throw new Error('Missing Supabase environment variables')
}
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
// Email confirmation and password recovery are handled by route components.
detectSessionInUrl: false,
},
})
+4
View File
@@ -56,8 +56,10 @@ const router = createRouter({
name: 'encyclopedia',
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
meta: {
requiresAuth: true,
title: '云朵图鉴',
description: '浏览 10 种基础云属,了解云型特征,并通过拍摄云图点亮个人图鉴。',
noindex: true,
},
},
{
@@ -65,8 +67,10 @@ const router = createRouter({
name: 'cloud-type',
component: () => import('@/views/encyclopedia/CloudTypeView.vue'),
meta: {
requiresAuth: true,
title: route => getCloudTypeName(route.params.id),
description: route => `查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`,
noindex: true,
},
},
{
+68 -116
View File
@@ -1,149 +1,102 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase'
import type { User } from '@supabase/supabase-js'
import {
AUTH_EXPIRED_EVENT,
AUTH_UPDATED_EVENT,
apiRequest,
clearAccessToken,
refreshSession,
setAccessToken,
} from '@/lib/api'
import type { AuthResponse, AuthUser } from '@/types/api'
import type { Profile } from '@/types/database'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const user = ref<AuthUser | null>(null)
const profile = ref<Profile | null>(null)
const loading = ref(true)
let listenersAttached = false
const isLoggedIn = computed(() => !!user.value)
const isAdmin = computed(() => profile.value?.role === 'admin')
async function fetchProfile(userId?: string) {
const id = userId ?? user.value?.id
if (!id) return
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.single()
if (data) {
profile.value = data as Profile
}
function applyAuth(auth: AuthResponse) {
setAccessToken(auth.access_token)
user.value = auth.user
profile.value = auth.profile
}
async function ensureUsernameAvailable(username: string, currentUserId?: string) {
let query = supabase
.from('profiles')
.select('id')
.eq('username', username)
.limit(1)
if (currentUserId) {
query = query.neq('id', currentUserId)
}
const { data, error } = await query
if (error) throw error
if (data?.length) {
throw new Error('这个昵称已经被使用,请换一个。')
}
}
async function login(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
if (error.message.includes('Invalid login credentials')) {
throw new Error('邮箱或密码错误')
}
if (error.message.includes('Email not confirmed')) {
throw new Error('邮箱未确认,请先查收确认邮件')
}
throw error
}
await fetchProfile(data.user.id)
user.value = data.user
}
async function register(email: string, password: string, username: string) {
await ensureUsernameAvailable(username)
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: { username },
emailRedirectTo: `${window.location.origin}/auth/confirm`,
},
})
if (error) {
if (error.message.includes('already registered')) {
throw new Error('该邮箱已被注册')
}
if (error.message.includes('Password')) {
throw new Error('密码强度不足,请使用至少6位密码')
}
throw error
}
}
async function logout() {
const { error } = await supabase.auth.signOut()
if (error) throw error
function clearAuth() {
clearAccessToken()
user.value = null
profile.value = null
}
async function updateUsername(username: string) {
if (!user.value) throw new Error('请先登录。')
async function login(email: string, password: string) {
const auth = await apiRequest<AuthResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { email, password },
})
applyAuth(auth)
}
await ensureUsernameAvailable(username, user.value.id)
async function register(email: string, password: string, username: string) {
await apiRequest('/auth/register', {
method: 'POST',
auth: false,
body: { email, password, username },
})
}
const { data, error } = await supabase
.from('profiles')
.update({ username })
.eq('id', user.value.id)
.select('*')
.single()
if (error) {
if (error.code === '23505') {
throw new Error('这个昵称已经被使用,请换一个。')
}
throw error
async function logout() {
try {
await apiRequest('/auth/logout', { method: 'POST', retry: false })
} finally {
clearAuth()
}
}
profile.value = data as Profile
async function updateUsername(username: string) {
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
method: 'PATCH',
body: { username },
})
profile.value = updatedProfile
}
async function updatePassword(password: string) {
const { error } = await supabase.auth.updateUser({ password })
if (error) throw error
const auth = await apiRequest<AuthResponse>('/auth/password', {
method: 'PATCH',
body: { password },
})
applyAuth(auth)
}
async function sendPasswordReset(email: string) {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
await apiRequest('/auth/forgot-password', {
method: 'POST',
auth: false,
body: { email },
})
if (error) {
if (error.message.includes('rate limit')) {
throw new Error('发送过于频繁,请稍后再试。')
}
throw error
}
}
async function initialize() {
const { data: { session } } = await supabase.auth.getSession()
const initialUser = session?.user ?? null
if (initialUser) await fetchProfile(initialUser.id)
user.value = initialUser
loading.value = false
if (!listenersAttached) {
window.addEventListener(AUTH_UPDATED_EVENT, event => {
applyAuth((event as CustomEvent<AuthResponse>).detail)
})
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
listenersAttached = true
}
supabase.auth.onAuthStateChange((_event, session) => {
const nextUser = session?.user ?? null
if (nextUser) {
fetchProfile(nextUser.id).then(() => {
user.value = nextUser
})
} else {
user.value = null
profile.value = null
}
})
try {
applyAuth(await refreshSession())
} catch {
clearAuth()
} finally {
loading.value = false
}
}
return {
@@ -152,7 +105,6 @@ export const useAuthStore = defineStore('auth', () => {
loading,
isLoggedIn,
isAdmin,
fetchProfile,
login,
register,
logout,
+6 -9
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { supabase } from '@/lib/supabase'
import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import type { CloudType } from '@/types/database'
export const useCloudsStore = defineStore('clouds', () => {
@@ -10,14 +10,11 @@ export const useCloudsStore = defineStore('clouds', () => {
async function fetchCloudTypes() {
if (cloudTypes.value.length > 0) return
loading.value = true
const { data } = await supabase
.from('cloud_types')
.select('*')
.order('id')
if (data) {
cloudTypes.value = data as CloudType[]
try {
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
} finally {
loading.value = false
}
loading.value = false
}
return { cloudTypes, loading, fetchCloudTypes }
+12 -71
View File
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ApiCollection } from '@/types/api'
import type { CloudType, UserCollection } from '@/types/database'
export interface CollectionPreviewCloud {
@@ -18,27 +19,7 @@ export interface CollectionEntry extends UserCollection {
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
return error.message
}
return '图鉴收藏加载失败'
}
function toCollectionCloudMap(rows: Array<Record<string, unknown>> | null) {
return new Map(
(rows || []).map(row => [
row.id as string,
{
id: row.id as string,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
location_name: (row.location_name as string | null) ?? null,
} satisfies CollectionPreviewCloud,
]),
)
return error instanceof Error ? error.message : '图鉴收藏加载失败'
}
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
@@ -52,30 +33,18 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
const collectionLoadedForUserId = ref<string | null>(null)
const collectionError = ref('')
const collectionMap = computed(() => {
return new Map(myCollection.value.map(item => [item.cloud_type_id, item]))
})
const collectionMap = computed(() => new Map(myCollection.value.map(item => [item.cloud_type_id, item])))
const unlockedCount = computed(() => myCollection.value.length)
const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`)
const unlockPercent = computed(() => {
if (!cloudTypes.value.length) return 0
return Math.round(unlockedCount.value / cloudTypes.value.length * 100)
})
const unlockPercent = computed(() => cloudTypes.value.length
? Math.round(unlockedCount.value / cloudTypes.value.length * 100)
: 0)
async function fetchCloudTypes(force = false) {
if (cloudTypesLoaded.value && !force) return
loadingCloudTypes.value = true
try {
const { data, error } = await supabase
.from('cloud_types')
.select('*')
.order('id')
if (error) throw error
cloudTypes.value = (data || []) as CloudType[]
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
cloudTypesLoaded.value = true
} finally {
loadingCloudTypes.value = false
@@ -84,49 +53,21 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
async function fetchMyCollection(force = false) {
const userId = authStore.user?.id ?? null
if (!userId) {
myCollection.value = []
collectionLoadedForUserId.value = null
collectionError.value = ''
return
}
if (!force && collectionLoadedForUserId.value === userId) return
loadingCollection.value = true
collectionError.value = ''
try {
const { data, error } = await supabase
.from('user_collections')
.select('*')
.eq('user_id', userId)
.order('unlocked_at', { ascending: true })
if (error) throw error
const collectionRows = (data || []) as UserCollection[]
const firstCloudIds = collectionRows
.map(item => item.first_cloud_id)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
let firstCloudMap = new Map<string, CollectionPreviewCloud>()
if (firstCloudIds.length) {
const { data: firstCloudData, error: firstCloudError } = await supabase
.from('clouds')
.select('id,image_url,thumbnail_url,captured_at,created_at,location_name')
.in('id', firstCloudIds)
if (firstCloudError) throw firstCloudError
firstCloudMap = toCollectionCloudMap(firstCloudData as Array<Record<string, unknown>> | null)
}
myCollection.value = collectionRows.map(item => ({
...item,
firstCloud: item.first_cloud_id ? firstCloudMap.get(item.first_cloud_id) ?? null : null,
const rows = await apiRequest<ApiCollection[]>('/collections/me')
myCollection.value = rows.map(({ first_cloud, cloud_type: _cloudType, ...collection }) => ({
...collection,
firstCloud: first_cloud,
}))
collectionLoadedForUserId.value = userId
} catch (error) {
+70 -141
View File
@@ -1,6 +1,8 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
export interface ProfileCloudItem {
@@ -21,52 +23,36 @@ export interface ProfileCloudItem {
cloudTypeRarity: CloudType['rarity']
}
function toProfileCloud(row: Record<string, unknown>) {
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
return {
id: row.id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as ProfileCloudItem['status'],
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
} satisfies ProfileCloudItem
id: row.id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
}
}
function getSupabaseErrorCode(error: unknown) {
if (!error || typeof error !== 'object' || !('code' in error)) return null
const code = (error as { code?: unknown }).code
return typeof code === 'string' ? code : null
}
function getCloudStoragePath(publicUrl: string | null) {
if (!publicUrl) return null
try {
const url = new URL(publicUrl)
const marker = '/storage/v1/object/public/clouds/'
const markerIndex = url.pathname.indexOf(marker)
if (markerIndex === -1) return null
const path = url.pathname.slice(markerIndex + marker.length)
return path ? decodeURIComponent(path) : null
} catch {
return null
function toProfile(profile: PublicProfile): Profile {
return {
...profile,
role: 'user',
is_disabled: false,
}
}
export const useProfileStore = defineStore('profile-page', () => {
const authStore = useAuthStore()
const profilesById = ref<Record<string, Profile>>({})
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
const loadingKeys = ref<Record<string, boolean>>({})
@@ -75,23 +61,32 @@ export const useProfileStore = defineStore('profile-page', () => {
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
function getProfile(userId: string | null) {
if (!userId) return null
return profilesById.value[userId] ?? null
return userId ? profilesById.value[userId] ?? null : null
}
function getClouds(userId: string | null, isOwnProfile: boolean) {
if (!userId) return []
return cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? []
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
}
function getError(userId: string | null, isOwnProfile: boolean) {
if (!userId) return ''
return errorByKey.value[makeKey(userId, isOwnProfile)] ?? ''
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
}
function isLoaded(userId: string | null, isOwnProfile: boolean) {
if (!userId) return false
return makeKey(userId, isOwnProfile) in cloudsByKey.value
return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value
}
async function fetchAllClouds(userId: string, isOwnProfile: boolean) {
const requestOptions = { auth: isOwnProfile }
const first = await apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=1&page_size=100`, requestOptions)
if (first.total_pages <= 1) return first.items
const remaining = await Promise.all(
Array.from({ length: first.total_pages - 1 }, (_, index) =>
apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=${index + 2}&page_size=100`, requestOptions),
),
)
return [first, ...remaining].flatMap(page => page.items)
}
async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
@@ -100,34 +95,13 @@ export const useProfileStore = defineStore('profile-page', () => {
loadingKeys.value[key] = true
errorByKey.value[key] = ''
try {
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single()
if (profileError) throw profileError
profilesById.value[userId] = profile as Profile
let cloudsQuery = supabase
.from('clouds')
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
.eq('user_id', userId)
.order('captured_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
if (!isOwnProfile) {
cloudsQuery = cloudsQuery
.eq('status', 'approved')
.eq('is_hidden', false)
}
const { data: cloudRows, error: cloudsError } = await cloudsQuery
if (cloudsError) throw cloudsError
cloudsByKey.value[key] = ((cloudRows || []) as Array<Record<string, unknown>>).map(toProfileCloud)
const profilePromise = isOwnProfile && authStore.profile?.id === userId
? Promise.resolve(authStore.profile)
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
profilesById.value[userId] = profile
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
} catch (error) {
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
cloudsByKey.value[key] = []
@@ -137,8 +111,7 @@ export const useProfileStore = defineStore('profile-page', () => {
}
function isLoading(userId: string | null, isOwnProfile: boolean) {
if (!userId) return false
return !!loadingKeys.value[makeKey(userId, isOwnProfile)]
return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
}
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
@@ -146,9 +119,8 @@ export const useProfileStore = defineStore('profile-page', () => {
const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key]
if (!current) continue
cloudsByKey.value[key] = current
.map(item => (item.id === cloudId ? { ...item, ...patch } : item))
.map(item => item.id === cloudId ? { ...item, ...patch } : item)
.filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden))
}
}
@@ -158,8 +130,7 @@ export const useProfileStore = defineStore('profile-page', () => {
for (const isOwnProfile of [true, false]) {
const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key]
if (!current) continue
cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
}
}
@@ -178,80 +149,38 @@ export const useProfileStore = defineStore('profile-page', () => {
captured_at: string | null
is_hidden: boolean
}) {
const { data, error } = await supabase
.from('clouds')
.update(patch)
.eq('id', cloudId)
.eq('user_id', userId)
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
.single()
if (error) throw error
const updated = toProfileCloud(data as Record<string, unknown>)
const updated = toProfileCloud(await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
method: 'PATCH',
body: patch,
}))
patchCachedCloud(userId, cloudId, updated)
return updated
}
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.eq('id', cloudId)
.eq('user_id', userId)
.select('id')
if (error) throw error
if (!data?.length) {
throw new Error('私密状态没有写入数据库,请检查 clouds 表的 UPDATE RLS policy。')
}
await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
method: 'PATCH',
body: { is_hidden: isHidden },
})
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
}
async function deleteClouds(userId: string, cloudIds: string[]) {
if (!cloudIds.length) return 0
const { data, error } = await supabase
.from('clouds')
.delete()
.eq('user_id', userId)
.in('id', cloudIds)
.select('id,image_url,thumbnail_url')
if (error) {
if (getSupabaseErrorCode(error) === '23503') {
throw new Error('这张照片仍被图鉴收藏记录引用,请先在数据库把 user_collections.first_cloud_id 外键改为 ON DELETE SET NULL。')
}
throw error
}
const deletedIds = (data || []).map(item => item.id as string)
if (deletedIds.length !== cloudIds.length) {
throw new Error('图片没有真正从数据库删除,请检查 clouds 表的 DELETE RLS policy。')
}
const storagePaths = Array.from(new Set(
(data || [])
.flatMap(item => [
getCloudStoragePath((item.image_url as string | null) ?? null),
getCloudStoragePath((item.thumbnail_url as string | null) ?? null),
])
.filter((path): path is string => !!path),
))
if (storagePaths.length) {
const { error: storageError } = await supabase.storage
.from('clouds')
.remove(storagePaths)
if (storageError) {
throw new Error(`图片数据库记录已删除,但 Supabase Storage 文件清理失败:${storageError.message}`)
if (cloudIds.length === 1) {
await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' })
} else {
for (let offset = 0; offset < cloudIds.length; offset += 100) {
await apiRequest('/clouds/batch-delete', {
method: 'POST',
body: { ids: cloudIds.slice(offset, offset + 100) },
})
}
}
removeCachedClouds(userId, deletedIds)
return deletedIds.length
removeCachedClouds(userId, cloudIds)
return cloudIds.length
}
return {
+2 -2
View File
@@ -38,7 +38,7 @@ body {
.oc-primary-button--teal.n-button {
border-color: rgb(153 246 228) !important;
background: rgb(242 250 247) !important;
background: rgb(255, 255, 255) !important;
color: rgb(17 94 89) !important;
box-shadow: 4px 4px 0 0 rgba(20, 184, 166, 0.1) !important;
}
@@ -56,7 +56,7 @@ body {
.oc-primary-button--sky.n-button {
border-color: rgb(186 230 253) !important;
background: rgb(224 242 254) !important;
background: rgb(255, 255, 255) !important;
color: rgb(7 89 133) !important;
box-shadow: 4px 4px 0 0 rgba(14, 165, 233, 0.14) !important;
}
+93
View File
@@ -0,0 +1,93 @@
import type { Cloud, CloudType, Profile, UserCollection } from '@/types/database'
export interface AuthUser {
id: string
email: string
email_verified: boolean
created_at: string
}
export interface AuthResponse {
access_token: string
token_type: 'bearer'
expires_in: number
user: AuthUser
profile: Profile
}
export interface MeResponse {
user: AuthUser
profile: Profile
}
export interface PublicProfile {
id: string
username: string
avatar_url: string | null
created_at: string
}
export interface ApiPage<T> {
items: T[]
page: number
page_size: number
total: number
total_pages: number
}
export interface ApiCloudTypeSummary {
id: number
name: string
name_en: string
rarity: CloudType['rarity']
}
export interface ApiCloudOwner {
id: string
username: string
}
export interface ApiCloud extends Cloud {
updated_at: string
cloud_type: ApiCloudTypeSummary | null
owner: ApiCloudOwner | null
cloud_type_name: string | null
cloud_type_rarity: CloudType['rarity'] | null
username: string | null
}
export interface ApiCollection extends UserCollection {
cloud_type: ApiCloudTypeSummary
first_cloud: Pick<
ApiCloud,
'id' | 'image_url' | 'thumbnail_url' | 'captured_at' | 'created_at' | 'location_name'
> | null
}
export interface UnlockedBadge {
cloud_type_id: number
cloud_name: string
cloud_name_en: string
rarity: CloudType['rarity']
unlocked_at: string
}
export interface CloudCreateResponse {
cloud: ApiCloud
unlocked_badge: UnlockedBadge | null
}
export interface AdminStats {
users: number
images: number
today_uploads: number
pending: number
approved: number
rejected: number
hidden: number
}
export interface AdminUserItem {
user: AuthUser
profile: Profile
}
-2
View File
@@ -24,8 +24,6 @@ export interface Cloud {
status: 'pending' | 'approved' | 'rejected'
is_hidden: boolean
created_at: string
cloud_types?: CloudType
profiles?: Profile
}
export interface Profile {
+2
View File
@@ -6,5 +6,7 @@ declare module 'vue-router' {
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
canonicalPath?: string
noindex?: boolean
requiresAuth?: boolean
requiresAdmin?: boolean
}
}
+67 -174
View File
@@ -4,9 +4,9 @@ import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'nai
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useProfileStore } from '@/stores/profile'
import type { AdminStats, AdminUserItem, ApiCloud, ApiPage } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
@@ -44,7 +44,6 @@ interface DashboardStats {
}
const authStore = useAuthStore()
const profileStore = useProfileStore()
const message = useMessage()
const activeTab = ref<AdminTab>('dashboard')
@@ -110,33 +109,25 @@ const cloudTypeStats = computed(() => {
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
function readJoinedOne(value: unknown) {
const rows = Array.isArray(value) ? value : value ? [value] : []
return rows[0] as Record<string, unknown> | undefined
}
function toAdminCloud(row: Record<string, unknown>): AdminCloud {
const cloudType = readJoinedOne(row.cloud_types)
const profile = readJoinedOne(row.profiles)
function toAdminCloud(row: ApiCloud): AdminCloud {
return {
id: row.id as string,
user_id: row.user_id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as CloudStatus,
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知云型',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名用户',
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型',
cloudTypeRarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名用户',
}
}
@@ -156,104 +147,30 @@ function formatCoordinate(value: number | null) {
}
function getErrorMessage(error: unknown, fallback: string) {
if (!error || typeof error !== 'object') return fallback
const payload = error as {
message?: unknown
code?: unknown
details?: unknown
hint?: unknown
}
const parts = [
typeof payload.message === 'string' ? payload.message : '',
typeof payload.code === 'string' ? `错误码:${payload.code}` : '',
typeof payload.details === 'string' ? payload.details : '',
typeof payload.hint === 'string' ? `提示:${payload.hint}` : '',
].filter(Boolean)
return parts.length ? parts.join(';') : fallback
}
function todayIsoStart() {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date.toISOString()
}
async function countProfiles() {
const { count, error } = await supabase
.from('profiles')
.select('*', { head: true, count: 'exact' })
if (error) throw error
return count || 0
}
async function countClouds(filters: {
status?: CloudStatus
isHidden?: boolean
createdAfter?: string
} = {}) {
let query = supabase
.from('clouds')
.select('*', { head: true, count: 'exact' })
if (filters.status) query = query.eq('status', filters.status)
if (typeof filters.isHidden === 'boolean') query = query.eq('is_hidden', filters.isHidden)
if (filters.createdAfter) query = query.gte('created_at', filters.createdAfter)
const { count, error } = await query
if (error) throw error
return count || 0
return error instanceof Error ? error.message : fallback
}
async function fetchStats() {
const [
userCount,
imageCount,
todayUploads,
pendingCount,
approvedCount,
rejectedCount,
hiddenCount,
] = await Promise.all([
countProfiles(),
countClouds(),
countClouds({ createdAfter: todayIsoStart() }),
countClouds({ status: 'pending' }),
countClouds({ status: 'approved' }),
countClouds({ status: 'rejected' }),
countClouds({ isHidden: true }),
])
const stats = await apiRequest<AdminStats>('/admin/stats')
dashboardStats.value = {
users: userCount,
images: imageCount,
todayUploads,
pending: pendingCount,
approved: approvedCount,
rejected: rejectedCount,
hidden: hiddenCount,
users: stats.users,
images: stats.images,
todayUploads: stats.today_uploads,
pending: stats.pending,
approved: stats.approved,
rejected: stats.rejected,
hidden: stats.hidden,
}
}
async function fetchUsers() {
const { data, error } = await supabase
.from('profiles')
.select('id,username,avatar_url,role,is_disabled,created_at')
.order('created_at', { ascending: false })
.limit(100)
if (error) throw error
users.value = (data || []) as Profile[]
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
users.value = result.items.map(item => item.profile)
}
async function fetchImages() {
const { data, error } = await supabase
.from('clouds')
.select('id,user_id,cloud_type_id,custom_cloud_type,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,cloud_types(name,rarity),profiles(username)')
.order('created_at', { ascending: false })
.limit(120)
if (error) throw error
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
images.value = result.items.map(toAdminCloud)
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
}
@@ -327,6 +244,12 @@ function patchImages(ids: string[], patch: Partial<AdminCloud>) {
}
}
function chunkIds(ids: string[]) {
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
ids.slice(index * 100, (index + 1) * 100),
)
}
async function updateCloudStatus(ids: string[], status: CloudStatus) {
if (!ids.length) return
@@ -334,18 +257,11 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
loadError.value = ''
try {
let query = supabase
.from('clouds')
.update({ status })
.select('id')
query = ids.length === 1 ? query.eq('id', ids[0]) : query.in('id', ids)
const { data, error } = await query
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
for (const chunk of chunkIds(ids)) {
await apiRequest('/admin/clouds/status', {
method: 'PATCH',
body: { ids: chunk, status },
})
}
patchImages(ids, { status })
@@ -369,15 +285,11 @@ async function updateImageVisibility(ids: string[], isHidden: boolean) {
loadError.value = ''
try {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.in('id', ids)
.select('id')
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
for (const chunk of chunkIds(ids)) {
await apiRequest('/admin/clouds/visibility', {
method: 'PATCH',
body: { ids: chunk, is_hidden: isHidden },
})
}
patchImages(ids, { is_hidden: isHidden })
@@ -412,16 +324,11 @@ async function deleteImages(clouds: AdminCloud[]) {
try {
const idsToDelete = clouds.map(cloud => cloud.id)
const cloudsByUser = new Map<string, string[]>()
for (const cloud of clouds) {
const current = cloudsByUser.get(cloud.user_id) || []
current.push(cloud.id)
cloudsByUser.set(cloud.user_id, current)
}
for (const [userId, cloudIds] of cloudsByUser.entries()) {
await profileStore.deleteClouds(userId, cloudIds)
for (const chunk of chunkIds(idsToDelete)) {
await apiRequest('/admin/clouds/batch-delete', {
method: 'POST',
body: { ids: chunk },
})
}
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
@@ -455,18 +362,11 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
loadError.value = ''
try {
const { data, error } = await supabase
.from('profiles')
.update({ role })
.eq('id', user.id)
.select('id,role')
if (error) throw error
if (!data?.length) {
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { role },
})
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success('用户角色已更新')
} catch (error) {
const text = getErrorMessage(error, '用户角色更新失败')
@@ -488,18 +388,11 @@ async function toggleUserDisabled(user: Profile) {
try {
const nextDisabled = !user.is_disabled
const { data, error } = await supabase
.from('profiles')
.update({ is_disabled: nextDisabled })
.eq('id', user.id)
.select('id,is_disabled')
if (error) throw error
if (!data?.length) {
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { is_disabled: nextDisabled },
})
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) {
const text = getErrorMessage(error, '用户状态更新失败')
@@ -521,9 +414,9 @@ onMounted(loadAdminData)
<div>
<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。
</p>
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有操作都由 FastAPI 服务统一处理。
</p> -->
</div>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :loading="loading" @click="loadAdminData">
+11 -10
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
import { createDetachedAuthClient, resolveEmailAuthCallback } from '@/lib/authEmail'
import { apiRequest } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const state = ref<'loading' | 'success' | 'failed'>('loading')
@@ -22,18 +23,18 @@ function startCountdown() {
onMounted(async () => {
try {
const confirmClient = createDetachedAuthClient()
const result = await resolveEmailAuthCallback({
client: confirmClient,
allowedTypes: ['signup', 'email'],
})
if (!result.ok) {
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
state.value = 'failed'
return
}
await confirmClient.auth.signOut()
await apiRequest('/auth/confirm-email', {
method: 'POST',
auth: false,
body: { token },
})
window.history.replaceState(null, '', window.location.pathname)
state.value = 'success'
startCountdown()
} catch {
+13 -11
View File
@@ -30,16 +30,18 @@ async function handleLogin() {
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<section class="border border-slate-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#ffffff_48%,#eff6ff_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
登录后继续记录
<span class="block text-teal-700">你眼中的每一朵云</span>
</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
</p>
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="flex h-full flex-col justify-between border border-slate-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#ffffff_48%,#eff6ff_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<div>
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
登录后继续记录
<span class="block text-teal-700">你眼中的每一朵云</span>
</h1>
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
</p> -->
</div>
<div class="mt-8 flex gap-4">
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Collection</div>
@@ -54,7 +56,7 @@ async function handleLogin() {
</div>
</section>
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
+2 -2
View File
@@ -55,9 +55,9 @@ async function handleRegister() {
加入天空探索者
<span class="block text-sky-700">建立你的云图档案</span>
</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
</p>
</p> -->
</div>
<div class="mt-8 flex gap-4">
<div class="border border-slate-200 bg-white px-4 py-3">
+24 -38
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
import { resolveEmailAuthCallback } from '@/lib/authEmail'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const password = ref('')
@@ -13,10 +13,11 @@ const error = ref('')
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
const loading = ref(false)
const countdown = ref(5)
const resetToken = ref('')
let countdownTimer: ReturnType<typeof setInterval> | null = null
const canSubmit = computed(() =>
state.value === 'ready' && password.value.length >= 6 && password.value === confirmPassword.value,
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
)
function showInvalidRecoveryLink() {
@@ -26,19 +27,12 @@ function showInvalidRecoveryLink() {
function getResetErrorMessage(value: unknown) {
const message = value instanceof Error ? value.message : ''
if (
message.includes('Auth session missing')
|| message.includes('session_not_found')
|| message.includes('refresh_token_not_found')
|| message.includes('Invalid Refresh Token')
|| message.includes('otp_expired')
|| message.includes('expired')
) {
if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
return '密码重置链接无效或已过期,请重新发送邮件。'
}
if (message.includes('Password should be at least')) {
return '新密码至少需要 6 位。'
if (message.includes('8')) {
return '新密码至少需要 8 位。'
}
return message || '密码重置失败,请稍后重试。'
@@ -49,31 +43,27 @@ function startCountdown() {
countdown.value--
if (countdown.value <= 0) {
if (countdownTimer) clearInterval(countdownTimer)
router.push('/')
router.push('/login')
}
}, 1000)
}
async function initializeRecoverySession() {
const result = await resolveEmailAuthCallback({
client: supabase,
allowedTypes: ['recovery'],
})
if (!result.ok || !result.session?.user) {
error.value = getResetErrorMessage(result.ok ? '链接无效或已过期。' : result.error)
state.value = 'invalid'
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
showInvalidRecoveryLink()
return
}
resetToken.value = token
state.value = 'ready'
}
async function handleResetPassword() {
error.value = ''
if (password.value.length < 6) {
error.value = '新密码至少需要 6 位。'
if (password.value.length < 8) {
error.value = '新密码至少需要 8 位。'
return
}
if (password.value !== confirmPassword.value) {
@@ -81,17 +71,13 @@ async function handleResetPassword() {
return
}
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
if (sessionError || !session?.user) {
showInvalidRecoveryLink()
return
}
loading.value = true
try {
const { error: updateError } = await supabase.auth.updateUser({ password: password.value })
if (updateError) throw updateError
await apiRequest('/auth/reset-password', {
method: 'POST',
auth: false,
body: { token: resetToken.value, password: password.value },
})
window.history.replaceState(null, '', window.location.pathname)
countdown.value = 5
state.value = 'success'
@@ -123,12 +109,12 @@ onUnmounted(() => {
v-if="state === 'success'"
status="success"
title="密码已重置"
description="你已使用新密码完成更新,正在返回地图页。"
description="你已使用新密码完成更新,现在可以重新登录。"
>
<template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转地图页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/')">进入地图</NButton>
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
</div>
</template>
</NResult>
@@ -162,7 +148,7 @@ onUnmounted(() => {
required
show-password-on="click"
autocomplete="new-password"
placeholder="至少 6 位"
placeholder="至少 8 位"
/>
</NFormItem>
+16 -44
View File
@@ -4,9 +4,10 @@ import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database'
interface CloudGalleryItem {
@@ -82,49 +83,20 @@ function openGalleryDetail(item: CloudGalleryItem) {
}
async function loadGallery(typeId: number) {
const galleryQuery = supabase
.from('clouds')
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,profiles(username)')
.eq('cloud_type_id', typeId)
.eq('status', 'approved')
.eq('is_hidden', false)
.order('captured_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
.limit(24)
const countQuery = supabase
.from('clouds')
.select('*', { head: true, count: 'exact' })
.eq('cloud_type_id', typeId)
.eq('status', 'approved')
.eq('is_hidden', false)
const [{ data: galleryData, error: galleryError }, { count, error: countError }] = await Promise.all([
galleryQuery,
countQuery,
])
if (galleryError) throw galleryError
if (countError) throw countError
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => {
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
const profile = profiles[0] as Record<string, unknown> | undefined
return {
id: row.id as string,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
profiles: profile ? { username: profile.username as string } : null,
} satisfies CloudGalleryItem
})
publicCount.value = count || 0
const result = await apiRequest<ApiPage<ApiCloud>>(`/cloud-types/${typeId}/clouds?page=1&page_size=24`, { auth: false })
gallery.value = result.items.map(row => ({
id: row.id,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
profiles: row.username ? { username: row.username } : null,
}))
publicCount.value = result.total
}
async function loadPage() {
+26 -120
View File
@@ -5,11 +5,12 @@ import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useProfileStore } from '@/stores/profile'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database'
interface GalleryCloud {
@@ -87,135 +88,40 @@ function formatCoordinate(value: number | null) {
const normalizedSearch = computed(() => searchQuery.value.trim())
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
function sanitizeSearchTerm(term: string) {
return term.replace(/[(),*%]/g, ' ').replace(/\s+/g, ' ').trim()
}
function getMatchedCloudTypeIds(term: string) {
const lowerTerm = term.toLocaleLowerCase('zh-CN')
return cloudsStore.cloudTypes
.filter(type => {
return type.name.toLocaleLowerCase('zh-CN').includes(lowerTerm) ||
type.name_en.toLocaleLowerCase('zh-CN').includes(lowerTerm)
})
.map(type => type.id)
}
async function fetchUserIdsBySearch(term: string) {
const sanitized = sanitizeSearchTerm(term)
if (!sanitized) return []
const { data, error } = await supabase
.from('profiles')
.select('id')
.ilike('username', `%${sanitized}%`)
.limit(100)
if (error) throw error
return ((data || []) as Array<{ id: string }>).map(profile => profile.id)
}
function toGalleryCloud(row: Record<string, unknown>) {
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
const profile = profiles[0] as Record<string, unknown> | undefined
function toGalleryCloud(row: ApiCloud) {
return {
id: row.id as string,
user_id: row.user_id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as GalleryCloud['status'],
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名',
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
location_name: row.location_name,
description: row.description,
latitude: row.latitude,
longitude: row.longitude,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名',
} satisfies GalleryCloud
}
async function resolveSearchFilters() {
const search = normalizedSearch.value
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
if (isUserSearch.value && !usernameTerm) return null
if (search && !isUserSearch.value && !cloudTypeTerm) return null
let userIds: string[] = []
if (usernameTerm) {
userIds = await fetchUserIdsBySearch(usernameTerm)
if (userIds.length === 0) return null
}
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
return { usernameTerm, cloudTypeTerm, userIds, matchedCloudTypeIds }
}
const FULL_SELECT = 'id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)'
function buildFilteredQuery(selectStr: string, options?: { count?: 'exact'; head?: boolean }) {
let query = supabase
.from('clouds')
.select(selectStr, options as any)
.eq('status', 'approved')
.eq('is_hidden', false)
if (selectedTypeId.value !== 'all') {
query = query.eq('cloud_type_id', selectedTypeId.value)
}
return query
}
async function loadPage(page: number) {
loading.value = true
loadError.value = ''
try {
const filters = await resolveSearchFilters()
if (filters === null) {
galleryItems.value = []
totalCount.value = 0
currentPage.value = 1
loading.value = false
return
}
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
if (normalizedSearch.value) params.set('search', normalizedSearch.value)
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true })
let dataQuery = buildFilteredQuery(FULL_SELECT)
.order('created_at', { ascending: false })
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1)
if (filters.usernameTerm) {
countQuery = countQuery.in('user_id', filters.userIds)
dataQuery = dataQuery.in('user_id', filters.userIds)
} else if (filters.cloudTypeTerm) {
if (filters.matchedCloudTypeIds.length) {
const orFilter = `cloud_type_id.in.(${filters.matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${filters.cloudTypeTerm}*`
countQuery = countQuery.or(orFilter)
dataQuery = dataQuery.or(orFilter)
} else {
countQuery = countQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
dataQuery = dataQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
}
}
const [{ count }, { data, error }] = await Promise.all([countQuery, dataQuery])
if (error) throw error
totalCount.value = count ?? 0
galleryItems.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toGalleryCloud)
const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
totalCount.value = result.total
galleryItems.value = result.items.map(toGalleryCloud)
currentPage.value = page
} catch (error) {
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
+24 -31
View File
@@ -3,9 +3,10 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { loadAMap } from '@/lib/amap'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud } from '@/types/api'
import { NIcon } from 'naive-ui'
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
@@ -212,44 +213,36 @@ function hideHeader() {
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
}
function toCloudMarker(row: Record<string, unknown>): CloudMarkerData {
const ct = row.cloud_types as Record<string, unknown> | null
const pf = row.profiles as Record<string, unknown> | null
function toCloudMarker(row: ApiCloud): CloudMarkerData {
return {
id: row.id as string,
id: row.id,
latitude: row.latitude as number,
longitude: row.longitude as number,
imageUrl: row.image_url as string,
thumbnailUrl: (row.thumbnail_url as string | null) ?? null,
locationName: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
cloudTypeName: (ct?.name as string) || (row.custom_cloud_type as string) || '未知',
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
username: (pf?.username as string) || '匿名',
capturedAt: (row.captured_at as string) || (row.created_at as string),
createdAt: row.created_at as string,
imageUrl: row.image_url,
thumbnailUrl: row.thumbnail_url,
locationName: row.location_name,
description: row.description,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
rarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名',
capturedAt: row.captured_at || row.created_at,
createdAt: row.created_at,
}
}
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
const { data, error } = await supabase
.from('clouds')
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
.eq('status', 'approved')
.eq('is_hidden', false)
.not('latitude', 'is', null)
.not('longitude', 'is', null)
.gte(field, start.toISOString())
.lt(field, end.toISOString())
.order(field, { ascending: true })
.limit(1000)
if (error) {
statusText.value = `查询失败: ${error.message}`
const params = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString(),
time_field: field,
})
try {
const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
return rows.map(toCloudMarker)
} catch (error) {
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
return []
}
return ((data || []) as Array<Record<string, unknown>>).map(toCloudMarker)
}
async function loadRealtimeClouds() {
@@ -486,7 +479,7 @@ onUnmounted(() => {
</button>
</div>
<div class="absolute bottom-6 right-16 z-10 flex justify-end pointer-events-none">
<div class="absolute bottom-6 right-19 z-10 flex justify-end pointer-events-none">
<div class="pointer-events-auto flex items-center justify-end gap-3">
<Transition name="map-timeline-controls">
<div v-if="timelineControlsOpen" class="flex items-center gap-3">
+3 -3
View File
@@ -54,8 +54,8 @@ async function savePassword() {
error.value = ''
success.value = ''
if (newPassword.value.length < 6) {
error.value = '新密码至少需要 6 位。'
if (newPassword.value.length < 8) {
error.value = '新密码至少需要 8 位。'
return
}
if (newPassword.value !== confirmNewPassword.value) {
@@ -145,7 +145,7 @@ async function savePassword() {
<div>
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
修改后下次登录需要使用新密码。建议使用至少 6 位且不易猜测的密码。
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
</p>
</div>
<div class="grid gap-3">
+11 -11
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
import { Settings } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
@@ -70,12 +70,12 @@ const pageTitle = computed(() => {
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
})
const profileSubtitle = computed(() => {
if (isOwnProfile.value) {
return '回看你的云图记录、收集进度和拍摄节奏。'
}
return '公开展示的云图记录会按时间顺序陈列在这里。'
})
// const profileSubtitle = computed(() => {
// if (isOwnProfile.value) {
// return '回看你的云图记录、收集进度和拍摄节奏。'
// }
// return '公开展示的云图记录会按时间顺序陈列在这里。'
// })
const totalShots = computed(() => clouds.value.length)
@@ -526,13 +526,13 @@ watch(selectedUploadDate, async newValue => {
</button>
</RouterLink>
</div>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
{{ profileSubtitle }}
</p>
</p> -->
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
<NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
<NTag v-else :bordered="false">公开展示</NTag>
<!-- <NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
<NTag v-else :bordered="false">公开展示</NTag> -->
</div>
</div>
</div>