feat: enhance cloud image management
This commit is contained in:
+148
-1
@@ -5,8 +5,12 @@ import type { CloudType, Profile } from '@/types/database'
|
||||
|
||||
export interface ProfileCloudItem {
|
||||
id: string
|
||||
cloud_type_id: number | null
|
||||
custom_cloud_type: string | null
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
@@ -23,8 +27,12 @@ function toProfileCloud(row: Record<string, unknown>) {
|
||||
|
||||
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,
|
||||
@@ -36,6 +44,28 @@ function toProfileCloud(row: Record<string, unknown>) {
|
||||
} satisfies ProfileCloudItem
|
||||
}
|
||||
|
||||
function getSupabaseErrorCode(error: unknown) {
|
||||
if (!error || typeof error !== 'object' || !('code' in error)) return null
|
||||
const code = (error as { code?: unknown }).code
|
||||
return typeof code === 'string' ? code : null
|
||||
}
|
||||
|
||||
function getCloudStoragePath(publicUrl: string | null) {
|
||||
if (!publicUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(publicUrl)
|
||||
const marker = '/storage/v1/object/public/clouds/'
|
||||
const markerIndex = url.pathname.indexOf(marker)
|
||||
if (markerIndex === -1) return null
|
||||
|
||||
const path = url.pathname.slice(markerIndex + marker.length)
|
||||
return path ? decodeURIComponent(path) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useProfileStore = defineStore('profile-page', () => {
|
||||
const profilesById = ref<Record<string, Profile>>({})
|
||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||
@@ -83,7 +113,7 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
|
||||
let cloudsQuery = supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
|
||||
.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 })
|
||||
@@ -111,6 +141,119 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
return !!loadingKeys.value[makeKey(userId, isOwnProfile)]
|
||||
}
|
||||
|
||||
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
|
||||
for (const isOwnProfile of [true, false]) {
|
||||
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))
|
||||
.filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden))
|
||||
}
|
||||
}
|
||||
|
||||
function removeCachedClouds(userId: string, cloudIds: string[]) {
|
||||
const idSet = new Set(cloudIds)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateUser(userId: string) {
|
||||
delete cloudsByKey.value[makeKey(userId, true)]
|
||||
delete cloudsByKey.value[makeKey(userId, false)]
|
||||
}
|
||||
|
||||
async function updateCloud(userId: string, cloudId: string, patch: {
|
||||
cloud_type_id: number | null
|
||||
custom_cloud_type: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
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>)
|
||||
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。')
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
removeCachedClouds(userId, deletedIds)
|
||||
return deletedIds.length
|
||||
}
|
||||
|
||||
return {
|
||||
getProfile,
|
||||
getClouds,
|
||||
@@ -118,5 +261,9 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
isLoaded,
|
||||
isLoading,
|
||||
fetchProfilePage,
|
||||
invalidateUser,
|
||||
updateCloud,
|
||||
updateCloudVisibility,
|
||||
deleteClouds,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user