refactor: switch to fastapi backend

This commit is contained in:
2026-07-18 14:48:45 +08:00
parent 9fd47e01f5
commit 613452b7fe
22 changed files with 701 additions and 1342 deletions
+65 -172
View File
@@ -4,9 +4,9 @@ import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'nai
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useProfileStore } from '@/stores/profile'
import type { AdminStats, AdminUserItem, ApiCloud, ApiPage } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
@@ -44,7 +44,6 @@ interface DashboardStats {
}
const authStore = useAuthStore()
const profileStore = useProfileStore()
const message = useMessage()
const activeTab = ref<AdminTab>('dashboard')
@@ -110,33 +109,25 @@ const cloudTypeStats = computed(() => {
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
function readJoinedOne(value: unknown) {
const rows = Array.isArray(value) ? value : value ? [value] : []
return rows[0] as Record<string, unknown> | undefined
}
function toAdminCloud(row: Record<string, unknown>): AdminCloud {
const cloudType = readJoinedOne(row.cloud_types)
const profile = readJoinedOne(row.profiles)
function toAdminCloud(row: ApiCloud): AdminCloud {
return {
id: row.id as string,
user_id: row.user_id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as CloudStatus,
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知云型',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名用户',
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型',
cloudTypeRarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名用户',
}
}
@@ -156,104 +147,30 @@ function formatCoordinate(value: number | null) {
}
function getErrorMessage(error: unknown, fallback: string) {
if (!error || typeof error !== 'object') return fallback
const payload = error as {
message?: unknown
code?: unknown
details?: unknown
hint?: unknown
}
const parts = [
typeof payload.message === 'string' ? payload.message : '',
typeof payload.code === 'string' ? `错误码:${payload.code}` : '',
typeof payload.details === 'string' ? payload.details : '',
typeof payload.hint === 'string' ? `提示:${payload.hint}` : '',
].filter(Boolean)
return parts.length ? parts.join(';') : fallback
}
function todayIsoStart() {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date.toISOString()
}
async function countProfiles() {
const { count, error } = await supabase
.from('profiles')
.select('*', { head: true, count: 'exact' })
if (error) throw error
return count || 0
}
async function countClouds(filters: {
status?: CloudStatus
isHidden?: boolean
createdAfter?: string
} = {}) {
let query = supabase
.from('clouds')
.select('*', { head: true, count: 'exact' })
if (filters.status) query = query.eq('status', filters.status)
if (typeof filters.isHidden === 'boolean') query = query.eq('is_hidden', filters.isHidden)
if (filters.createdAfter) query = query.gte('created_at', filters.createdAfter)
const { count, error } = await query
if (error) throw error
return count || 0
return error instanceof Error ? error.message : fallback
}
async function fetchStats() {
const [
userCount,
imageCount,
todayUploads,
pendingCount,
approvedCount,
rejectedCount,
hiddenCount,
] = await Promise.all([
countProfiles(),
countClouds(),
countClouds({ createdAfter: todayIsoStart() }),
countClouds({ status: 'pending' }),
countClouds({ status: 'approved' }),
countClouds({ status: 'rejected' }),
countClouds({ isHidden: true }),
])
const stats = await apiRequest<AdminStats>('/admin/stats')
dashboardStats.value = {
users: userCount,
images: imageCount,
todayUploads,
pending: pendingCount,
approved: approvedCount,
rejected: rejectedCount,
hidden: hiddenCount,
users: stats.users,
images: stats.images,
todayUploads: stats.today_uploads,
pending: stats.pending,
approved: stats.approved,
rejected: stats.rejected,
hidden: stats.hidden,
}
}
async function fetchUsers() {
const { data, error } = await supabase
.from('profiles')
.select('id,username,avatar_url,role,is_disabled,created_at')
.order('created_at', { ascending: false })
.limit(100)
if (error) throw error
users.value = (data || []) as Profile[]
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
users.value = result.items.map(item => item.profile)
}
async function fetchImages() {
const { data, error } = await supabase
.from('clouds')
.select('id,user_id,cloud_type_id,custom_cloud_type,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,cloud_types(name,rarity),profiles(username)')
.order('created_at', { ascending: false })
.limit(120)
if (error) throw error
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
images.value = result.items.map(toAdminCloud)
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
}
@@ -327,6 +244,12 @@ function patchImages(ids: string[], patch: Partial<AdminCloud>) {
}
}
function chunkIds(ids: string[]) {
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
ids.slice(index * 100, (index + 1) * 100),
)
}
async function updateCloudStatus(ids: string[], status: CloudStatus) {
if (!ids.length) return
@@ -334,18 +257,11 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
loadError.value = ''
try {
let query = supabase
.from('clouds')
.update({ status })
.select('id')
query = ids.length === 1 ? query.eq('id', ids[0]) : query.in('id', ids)
const { data, error } = await query
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
for (const chunk of chunkIds(ids)) {
await apiRequest('/admin/clouds/status', {
method: 'PATCH',
body: { ids: chunk, status },
})
}
patchImages(ids, { status })
@@ -369,15 +285,11 @@ async function updateImageVisibility(ids: string[], isHidden: boolean) {
loadError.value = ''
try {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.in('id', ids)
.select('id')
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
for (const chunk of chunkIds(ids)) {
await apiRequest('/admin/clouds/visibility', {
method: 'PATCH',
body: { ids: chunk, is_hidden: isHidden },
})
}
patchImages(ids, { is_hidden: isHidden })
@@ -412,16 +324,11 @@ async function deleteImages(clouds: AdminCloud[]) {
try {
const idsToDelete = clouds.map(cloud => cloud.id)
const cloudsByUser = new Map<string, string[]>()
for (const cloud of clouds) {
const current = cloudsByUser.get(cloud.user_id) || []
current.push(cloud.id)
cloudsByUser.set(cloud.user_id, current)
}
for (const [userId, cloudIds] of cloudsByUser.entries()) {
await profileStore.deleteClouds(userId, cloudIds)
for (const chunk of chunkIds(idsToDelete)) {
await apiRequest('/admin/clouds/batch-delete', {
method: 'POST',
body: { ids: chunk },
})
}
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
@@ -455,18 +362,11 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
loadError.value = ''
try {
const { data, error } = await supabase
.from('profiles')
.update({ role })
.eq('id', user.id)
.select('id,role')
if (error) throw error
if (!data?.length) {
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { role },
})
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success('用户角色已更新')
} catch (error) {
const text = getErrorMessage(error, '用户角色更新失败')
@@ -488,18 +388,11 @@ async function toggleUserDisabled(user: Profile) {
try {
const nextDisabled = !user.is_disabled
const { data, error } = await supabase
.from('profiles')
.update({ is_disabled: nextDisabled })
.eq('id', user.id)
.select('id,is_disabled')
if (error) throw error
if (!data?.length) {
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { is_disabled: nextDisabled },
})
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) {
const text = getErrorMessage(error, '用户状态更新失败')
@@ -522,7 +415,7 @@ onMounted(loadAdminData)
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-sky-700">Admin Console</p>
<h1 class="mt-3 text-4xl font-bold text-slate-950">管理后台</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都直接落到 Supabase。
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有操作都由 FastAPI 服务统一处理。
</p>
</div>