后端迁移到fastapi (#3)
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user