后端迁移到fastapi (#3)
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import {
|
||||
createClient,
|
||||
type EmailOtpType,
|
||||
type Session,
|
||||
type SupabaseClient,
|
||||
} from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
const knownEmailOtpTypes = new Set<EmailOtpType>([
|
||||
'signup',
|
||||
'invite',
|
||||
'magiclink',
|
||||
'recovery',
|
||||
'email_change',
|
||||
'email',
|
||||
])
|
||||
|
||||
type AuthCallbackParams = {
|
||||
accessToken: string | null
|
||||
code: string | null
|
||||
error: string | null
|
||||
errorCode: string | null
|
||||
errorDescription: string | null
|
||||
refreshToken: string | null
|
||||
tokenHash: string | null
|
||||
type: EmailOtpType | null
|
||||
}
|
||||
|
||||
type ResolveEmailAuthCallbackOptions = {
|
||||
allowedTypes: EmailOtpType[]
|
||||
client: SupabaseClient
|
||||
}
|
||||
|
||||
type ResolveEmailAuthCallbackResult =
|
||||
| {
|
||||
ok: true
|
||||
session: Session | null
|
||||
type: EmailOtpType | null
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
}
|
||||
|
||||
function parseEmailOtpType(value: string | null): EmailOtpType | null {
|
||||
if (!value || !knownEmailOtpTypes.has(value as EmailOtpType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value as EmailOtpType
|
||||
}
|
||||
|
||||
export function readAuthCallbackParams(): AuthCallbackParams {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1))
|
||||
|
||||
return {
|
||||
code: search.get('code'),
|
||||
tokenHash: search.get('token_hash') ?? hash.get('token_hash'),
|
||||
accessToken: hash.get('access_token'),
|
||||
refreshToken: hash.get('refresh_token'),
|
||||
type: parseEmailOtpType(search.get('type') ?? hash.get('type')),
|
||||
error: search.get('error') ?? hash.get('error'),
|
||||
errorCode: search.get('error_code') ?? hash.get('error_code'),
|
||||
errorDescription: search.get('error_description') ?? hash.get('error_description'),
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthCallbackUrl() {
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
|
||||
export function createDetachedAuthClient() {
|
||||
return createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
detectSessionInUrl: false,
|
||||
persistSession: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveEmailAuthCallback(
|
||||
options: ResolveEmailAuthCallbackOptions,
|
||||
): Promise<ResolveEmailAuthCallbackResult> {
|
||||
const { client, allowedTypes } = options
|
||||
const params = readAuthCallbackParams()
|
||||
const allowedTypeSet = new Set(allowedTypes)
|
||||
|
||||
if (params.error || params.errorCode || params.errorDescription) {
|
||||
return {
|
||||
ok: false,
|
||||
error: params.errorDescription || '链接无效或已过期。',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let session: Session | null = null
|
||||
|
||||
if (params.tokenHash && params.type && allowedTypeSet.has(params.type)) {
|
||||
const { data, error } = await client.auth.verifyOtp({
|
||||
token_hash: params.tokenHash,
|
||||
type: params.type,
|
||||
})
|
||||
if (error) throw error
|
||||
session = data.session
|
||||
} else if (params.code) {
|
||||
const { data, error } = await client.auth.exchangeCodeForSession(params.code)
|
||||
if (error) throw error
|
||||
session = data.session
|
||||
} else if (
|
||||
params.accessToken
|
||||
&& params.refreshToken
|
||||
&& params.type
|
||||
&& allowedTypeSet.has(params.type)
|
||||
) {
|
||||
const { data, error } = await client.auth.setSession({
|
||||
access_token: params.accessToken,
|
||||
refresh_token: params.refreshToken,
|
||||
})
|
||||
if (error) throw error
|
||||
session = data.session
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
error: '链接无效或已过期。',
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
const { data, error } = await client.auth.getSession()
|
||||
if (error) throw error
|
||||
session = data.session
|
||||
}
|
||||
|
||||
clearAuthCallbackUrl()
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
session,
|
||||
type: params.type,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '链接无效或已过期。'
|
||||
return {
|
||||
ok: false,
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
throw new Error('Missing Supabase environment variables')
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
// Email confirmation and password recovery are handled by route components.
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+2
@@ -6,5 +6,7 @@ declare module 'vue-router' {
|
||||
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
canonicalPath?: string
|
||||
noindex?: boolean
|
||||
requiresAuth?: boolean
|
||||
requiresAdmin?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
+67
-174
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user