后端迁移到fastapi (#3)

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-18 20:49:09 +08:00
parent 217b81c506
commit 51743645e2
71 changed files with 845 additions and 3580 deletions
+67 -174
View File
@@ -4,9 +4,9 @@ import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'nai
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useProfileStore } from '@/stores/profile'
import type { AdminStats, AdminUserItem, ApiCloud, ApiPage } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
@@ -44,7 +44,6 @@ interface DashboardStats {
}
const authStore = useAuthStore()
const profileStore = useProfileStore()
const message = useMessage()
const activeTab = ref<AdminTab>('dashboard')
@@ -110,33 +109,25 @@ const cloudTypeStats = computed(() => {
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
function readJoinedOne(value: unknown) {
const rows = Array.isArray(value) ? value : value ? [value] : []
return rows[0] as Record<string, unknown> | undefined
}
function toAdminCloud(row: Record<string, unknown>): AdminCloud {
const cloudType = readJoinedOne(row.cloud_types)
const profile = readJoinedOne(row.profiles)
function toAdminCloud(row: ApiCloud): AdminCloud {
return {
id: row.id as string,
user_id: row.user_id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as CloudStatus,
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知云型',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名用户',
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型',
cloudTypeRarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名用户',
}
}
@@ -156,104 +147,30 @@ function formatCoordinate(value: number | null) {
}
function getErrorMessage(error: unknown, fallback: string) {
if (!error || typeof error !== 'object') return fallback
const payload = error as {
message?: unknown
code?: unknown
details?: unknown
hint?: unknown
}
const parts = [
typeof payload.message === 'string' ? payload.message : '',
typeof payload.code === 'string' ? `错误码:${payload.code}` : '',
typeof payload.details === 'string' ? payload.details : '',
typeof payload.hint === 'string' ? `提示:${payload.hint}` : '',
].filter(Boolean)
return parts.length ? parts.join(';') : fallback
}
function todayIsoStart() {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date.toISOString()
}
async function countProfiles() {
const { count, error } = await supabase
.from('profiles')
.select('*', { head: true, count: 'exact' })
if (error) throw error
return count || 0
}
async function countClouds(filters: {
status?: CloudStatus
isHidden?: boolean
createdAfter?: string
} = {}) {
let query = supabase
.from('clouds')
.select('*', { head: true, count: 'exact' })
if (filters.status) query = query.eq('status', filters.status)
if (typeof filters.isHidden === 'boolean') query = query.eq('is_hidden', filters.isHidden)
if (filters.createdAfter) query = query.gte('created_at', filters.createdAfter)
const { count, error } = await query
if (error) throw error
return count || 0
return error instanceof Error ? error.message : fallback
}
async function fetchStats() {
const [
userCount,
imageCount,
todayUploads,
pendingCount,
approvedCount,
rejectedCount,
hiddenCount,
] = await Promise.all([
countProfiles(),
countClouds(),
countClouds({ createdAfter: todayIsoStart() }),
countClouds({ status: 'pending' }),
countClouds({ status: 'approved' }),
countClouds({ status: 'rejected' }),
countClouds({ isHidden: true }),
])
const stats = await apiRequest<AdminStats>('/admin/stats')
dashboardStats.value = {
users: userCount,
images: imageCount,
todayUploads,
pending: pendingCount,
approved: approvedCount,
rejected: rejectedCount,
hidden: hiddenCount,
users: stats.users,
images: stats.images,
todayUploads: stats.today_uploads,
pending: stats.pending,
approved: stats.approved,
rejected: stats.rejected,
hidden: stats.hidden,
}
}
async function fetchUsers() {
const { data, error } = await supabase
.from('profiles')
.select('id,username,avatar_url,role,is_disabled,created_at')
.order('created_at', { ascending: false })
.limit(100)
if (error) throw error
users.value = (data || []) as Profile[]
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
users.value = result.items.map(item => item.profile)
}
async function fetchImages() {
const { data, error } = await supabase
.from('clouds')
.select('id,user_id,cloud_type_id,custom_cloud_type,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,cloud_types(name,rarity),profiles(username)')
.order('created_at', { ascending: false })
.limit(120)
if (error) throw error
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
images.value = result.items.map(toAdminCloud)
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
}
@@ -327,6 +244,12 @@ function patchImages(ids: string[], patch: Partial<AdminCloud>) {
}
}
function chunkIds(ids: string[]) {
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
ids.slice(index * 100, (index + 1) * 100),
)
}
async function updateCloudStatus(ids: string[], status: CloudStatus) {
if (!ids.length) return
@@ -334,18 +257,11 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
loadError.value = ''
try {
let query = supabase
.from('clouds')
.update({ status })
.select('id')
query = ids.length === 1 ? query.eq('id', ids[0]) : query.in('id', ids)
const { data, error } = await query
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
for (const chunk of chunkIds(ids)) {
await apiRequest('/admin/clouds/status', {
method: 'PATCH',
body: { ids: chunk, status },
})
}
patchImages(ids, { status })
@@ -369,15 +285,11 @@ async function updateImageVisibility(ids: string[], isHidden: boolean) {
loadError.value = ''
try {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.in('id', ids)
.select('id')
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
for (const chunk of chunkIds(ids)) {
await apiRequest('/admin/clouds/visibility', {
method: 'PATCH',
body: { ids: chunk, is_hidden: isHidden },
})
}
patchImages(ids, { is_hidden: isHidden })
@@ -412,16 +324,11 @@ async function deleteImages(clouds: AdminCloud[]) {
try {
const idsToDelete = clouds.map(cloud => cloud.id)
const cloudsByUser = new Map<string, string[]>()
for (const cloud of clouds) {
const current = cloudsByUser.get(cloud.user_id) || []
current.push(cloud.id)
cloudsByUser.set(cloud.user_id, current)
}
for (const [userId, cloudIds] of cloudsByUser.entries()) {
await profileStore.deleteClouds(userId, cloudIds)
for (const chunk of chunkIds(idsToDelete)) {
await apiRequest('/admin/clouds/batch-delete', {
method: 'POST',
body: { ids: chunk },
})
}
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
@@ -455,18 +362,11 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
loadError.value = ''
try {
const { data, error } = await supabase
.from('profiles')
.update({ role })
.eq('id', user.id)
.select('id,role')
if (error) throw error
if (!data?.length) {
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { role },
})
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success('用户角色已更新')
} catch (error) {
const text = getErrorMessage(error, '用户角色更新失败')
@@ -488,18 +388,11 @@ async function toggleUserDisabled(user: Profile) {
try {
const nextDisabled = !user.is_disabled
const { data, error } = await supabase
.from('profiles')
.update({ is_disabled: nextDisabled })
.eq('id', user.id)
.select('id,is_disabled')
if (error) throw error
if (!data?.length) {
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { is_disabled: nextDisabled },
})
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) {
const text = getErrorMessage(error, '用户状态更新失败')
@@ -521,9 +414,9 @@ onMounted(loadAdminData)
<div>
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-sky-700">Admin Console</p>
<h1 class="mt-3 text-4xl font-bold text-slate-950">管理后台</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都直接落到 Supabase。
</p>
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有操作都由 FastAPI 服务统一处理。
</p> -->
</div>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :loading="loading" @click="loadAdminData">
+11 -10
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
import { createDetachedAuthClient, resolveEmailAuthCallback } from '@/lib/authEmail'
import { apiRequest } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const state = ref<'loading' | 'success' | 'failed'>('loading')
@@ -22,18 +23,18 @@ function startCountdown() {
onMounted(async () => {
try {
const confirmClient = createDetachedAuthClient()
const result = await resolveEmailAuthCallback({
client: confirmClient,
allowedTypes: ['signup', 'email'],
})
if (!result.ok) {
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
state.value = 'failed'
return
}
await confirmClient.auth.signOut()
await apiRequest('/auth/confirm-email', {
method: 'POST',
auth: false,
body: { token },
})
window.history.replaceState(null, '', window.location.pathname)
state.value = 'success'
startCountdown()
} catch {
+13 -11
View File
@@ -30,16 +30,18 @@ async function handleLogin() {
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<section class="border border-slate-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#ffffff_48%,#eff6ff_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
登录后继续记录
<span class="block text-teal-700">你眼中的每一朵云</span>
</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
</p>
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="flex h-full flex-col justify-between border border-slate-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#ffffff_48%,#eff6ff_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<div>
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
登录后继续记录
<span class="block text-teal-700">你眼中的每一朵云</span>
</h1>
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
</p> -->
</div>
<div class="mt-8 flex gap-4">
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Collection</div>
@@ -54,7 +56,7 @@ async function handleLogin() {
</div>
</section>
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
+2 -2
View File
@@ -55,9 +55,9 @@ async function handleRegister() {
加入天空探索者
<span class="block text-sky-700">建立你的云图档案</span>
</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
</p>
</p> -->
</div>
<div class="mt-8 flex gap-4">
<div class="border border-slate-200 bg-white px-4 py-3">
+24 -38
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
import { resolveEmailAuthCallback } from '@/lib/authEmail'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const password = ref('')
@@ -13,10 +13,11 @@ const error = ref('')
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
const loading = ref(false)
const countdown = ref(5)
const resetToken = ref('')
let countdownTimer: ReturnType<typeof setInterval> | null = null
const canSubmit = computed(() =>
state.value === 'ready' && password.value.length >= 6 && password.value === confirmPassword.value,
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
)
function showInvalidRecoveryLink() {
@@ -26,19 +27,12 @@ function showInvalidRecoveryLink() {
function getResetErrorMessage(value: unknown) {
const message = value instanceof Error ? value.message : ''
if (
message.includes('Auth session missing')
|| message.includes('session_not_found')
|| message.includes('refresh_token_not_found')
|| message.includes('Invalid Refresh Token')
|| message.includes('otp_expired')
|| message.includes('expired')
) {
if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
return '密码重置链接无效或已过期,请重新发送邮件。'
}
if (message.includes('Password should be at least')) {
return '新密码至少需要 6 位。'
if (message.includes('8')) {
return '新密码至少需要 8 位。'
}
return message || '密码重置失败,请稍后重试。'
@@ -49,31 +43,27 @@ function startCountdown() {
countdown.value--
if (countdown.value <= 0) {
if (countdownTimer) clearInterval(countdownTimer)
router.push('/')
router.push('/login')
}
}, 1000)
}
async function initializeRecoverySession() {
const result = await resolveEmailAuthCallback({
client: supabase,
allowedTypes: ['recovery'],
})
if (!result.ok || !result.session?.user) {
error.value = getResetErrorMessage(result.ok ? '链接无效或已过期。' : result.error)
state.value = 'invalid'
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token) {
showInvalidRecoveryLink()
return
}
resetToken.value = token
state.value = 'ready'
}
async function handleResetPassword() {
error.value = ''
if (password.value.length < 6) {
error.value = '新密码至少需要 6 位。'
if (password.value.length < 8) {
error.value = '新密码至少需要 8 位。'
return
}
if (password.value !== confirmPassword.value) {
@@ -81,17 +71,13 @@ async function handleResetPassword() {
return
}
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
if (sessionError || !session?.user) {
showInvalidRecoveryLink()
return
}
loading.value = true
try {
const { error: updateError } = await supabase.auth.updateUser({ password: password.value })
if (updateError) throw updateError
await apiRequest('/auth/reset-password', {
method: 'POST',
auth: false,
body: { token: resetToken.value, password: password.value },
})
window.history.replaceState(null, '', window.location.pathname)
countdown.value = 5
state.value = 'success'
@@ -123,12 +109,12 @@ onUnmounted(() => {
v-if="state === 'success'"
status="success"
title="密码已重置"
description="你已使用新密码完成更新,正在返回地图页。"
description="你已使用新密码完成更新,现在可以重新登录。"
>
<template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转地图页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/')">进入地图</NButton>
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
</div>
</template>
</NResult>
@@ -162,7 +148,7 @@ onUnmounted(() => {
required
show-password-on="click"
autocomplete="new-password"
placeholder="至少 6 位"
placeholder="至少 8 位"
/>
</NFormItem>
+16 -44
View File
@@ -4,9 +4,10 @@ import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database'
interface CloudGalleryItem {
@@ -82,49 +83,20 @@ function openGalleryDetail(item: CloudGalleryItem) {
}
async function loadGallery(typeId: number) {
const galleryQuery = supabase
.from('clouds')
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,profiles(username)')
.eq('cloud_type_id', typeId)
.eq('status', 'approved')
.eq('is_hidden', false)
.order('captured_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
.limit(24)
const countQuery = supabase
.from('clouds')
.select('*', { head: true, count: 'exact' })
.eq('cloud_type_id', typeId)
.eq('status', 'approved')
.eq('is_hidden', false)
const [{ data: galleryData, error: galleryError }, { count, error: countError }] = await Promise.all([
galleryQuery,
countQuery,
])
if (galleryError) throw galleryError
if (countError) throw countError
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => {
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
const profile = profiles[0] as Record<string, unknown> | undefined
return {
id: row.id as string,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
profiles: profile ? { username: profile.username as string } : null,
} satisfies CloudGalleryItem
})
publicCount.value = count || 0
const result = await apiRequest<ApiPage<ApiCloud>>(`/cloud-types/${typeId}/clouds?page=1&page_size=24`, { auth: false })
gallery.value = result.items.map(row => ({
id: row.id,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
profiles: row.username ? { username: row.username } : null,
}))
publicCount.value = result.total
}
async function loadPage() {
+26 -120
View File
@@ -5,11 +5,12 @@ import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useProfileStore } from '@/stores/profile'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database'
interface GalleryCloud {
@@ -87,135 +88,40 @@ function formatCoordinate(value: number | null) {
const normalizedSearch = computed(() => searchQuery.value.trim())
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
function sanitizeSearchTerm(term: string) {
return term.replace(/[(),*%]/g, ' ').replace(/\s+/g, ' ').trim()
}
function getMatchedCloudTypeIds(term: string) {
const lowerTerm = term.toLocaleLowerCase('zh-CN')
return cloudsStore.cloudTypes
.filter(type => {
return type.name.toLocaleLowerCase('zh-CN').includes(lowerTerm) ||
type.name_en.toLocaleLowerCase('zh-CN').includes(lowerTerm)
})
.map(type => type.id)
}
async function fetchUserIdsBySearch(term: string) {
const sanitized = sanitizeSearchTerm(term)
if (!sanitized) return []
const { data, error } = await supabase
.from('profiles')
.select('id')
.ilike('username', `%${sanitized}%`)
.limit(100)
if (error) throw error
return ((data || []) as Array<{ id: string }>).map(profile => profile.id)
}
function toGalleryCloud(row: Record<string, unknown>) {
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
const profile = profiles[0] as Record<string, unknown> | undefined
function toGalleryCloud(row: ApiCloud) {
return {
id: row.id as string,
user_id: row.user_id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as GalleryCloud['status'],
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名',
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
custom_cloud_type: row.custom_cloud_type,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
location_name: row.location_name,
description: row.description,
latitude: row.latitude,
longitude: row.longitude,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名',
} satisfies GalleryCloud
}
async function resolveSearchFilters() {
const search = normalizedSearch.value
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
if (isUserSearch.value && !usernameTerm) return null
if (search && !isUserSearch.value && !cloudTypeTerm) return null
let userIds: string[] = []
if (usernameTerm) {
userIds = await fetchUserIdsBySearch(usernameTerm)
if (userIds.length === 0) return null
}
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
return { usernameTerm, cloudTypeTerm, userIds, matchedCloudTypeIds }
}
const FULL_SELECT = 'id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)'
function buildFilteredQuery(selectStr: string, options?: { count?: 'exact'; head?: boolean }) {
let query = supabase
.from('clouds')
.select(selectStr, options as any)
.eq('status', 'approved')
.eq('is_hidden', false)
if (selectedTypeId.value !== 'all') {
query = query.eq('cloud_type_id', selectedTypeId.value)
}
return query
}
async function loadPage(page: number) {
loading.value = true
loadError.value = ''
try {
const filters = await resolveSearchFilters()
if (filters === null) {
galleryItems.value = []
totalCount.value = 0
currentPage.value = 1
loading.value = false
return
}
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
if (normalizedSearch.value) params.set('search', normalizedSearch.value)
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true })
let dataQuery = buildFilteredQuery(FULL_SELECT)
.order('created_at', { ascending: false })
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1)
if (filters.usernameTerm) {
countQuery = countQuery.in('user_id', filters.userIds)
dataQuery = dataQuery.in('user_id', filters.userIds)
} else if (filters.cloudTypeTerm) {
if (filters.matchedCloudTypeIds.length) {
const orFilter = `cloud_type_id.in.(${filters.matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${filters.cloudTypeTerm}*`
countQuery = countQuery.or(orFilter)
dataQuery = dataQuery.or(orFilter)
} else {
countQuery = countQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
dataQuery = dataQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
}
}
const [{ count }, { data, error }] = await Promise.all([countQuery, dataQuery])
if (error) throw error
totalCount.value = count ?? 0
galleryItems.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toGalleryCloud)
const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
totalCount.value = result.total
galleryItems.value = result.items.map(toGalleryCloud)
currentPage.value = page
} catch (error) {
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
+24 -31
View File
@@ -3,9 +3,10 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
import { supabase } from '@/lib/supabase'
import { apiRequest } from '@/lib/api'
import { loadAMap } from '@/lib/amap'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud } from '@/types/api'
import { NIcon } from 'naive-ui'
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
@@ -212,44 +213,36 @@ function hideHeader() {
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
}
function toCloudMarker(row: Record<string, unknown>): CloudMarkerData {
const ct = row.cloud_types as Record<string, unknown> | null
const pf = row.profiles as Record<string, unknown> | null
function toCloudMarker(row: ApiCloud): CloudMarkerData {
return {
id: row.id as string,
id: row.id,
latitude: row.latitude as number,
longitude: row.longitude as number,
imageUrl: row.image_url as string,
thumbnailUrl: (row.thumbnail_url as string | null) ?? null,
locationName: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
cloudTypeName: (ct?.name as string) || (row.custom_cloud_type as string) || '未知',
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
username: (pf?.username as string) || '匿名',
capturedAt: (row.captured_at as string) || (row.created_at as string),
createdAt: row.created_at as string,
imageUrl: row.image_url,
thumbnailUrl: row.thumbnail_url,
locationName: row.location_name,
description: row.description,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
rarity: row.cloud_type_rarity || 'common',
username: row.username || '匿名',
capturedAt: row.captured_at || row.created_at,
createdAt: row.created_at,
}
}
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
const { data, error } = await supabase
.from('clouds')
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
.eq('status', 'approved')
.eq('is_hidden', false)
.not('latitude', 'is', null)
.not('longitude', 'is', null)
.gte(field, start.toISOString())
.lt(field, end.toISOString())
.order(field, { ascending: true })
.limit(1000)
if (error) {
statusText.value = `查询失败: ${error.message}`
const params = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString(),
time_field: field,
})
try {
const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
return rows.map(toCloudMarker)
} catch (error) {
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
return []
}
return ((data || []) as Array<Record<string, unknown>>).map(toCloudMarker)
}
async function loadRealtimeClouds() {
@@ -486,7 +479,7 @@ onUnmounted(() => {
</button>
</div>
<div class="absolute bottom-6 right-16 z-10 flex justify-end pointer-events-none">
<div class="absolute bottom-6 right-19 z-10 flex justify-end pointer-events-none">
<div class="pointer-events-auto flex items-center justify-end gap-3">
<Transition name="map-timeline-controls">
<div v-if="timelineControlsOpen" class="flex items-center gap-3">
+3 -3
View File
@@ -54,8 +54,8 @@ async function savePassword() {
error.value = ''
success.value = ''
if (newPassword.value.length < 6) {
error.value = '新密码至少需要 6 位。'
if (newPassword.value.length < 8) {
error.value = '新密码至少需要 8 位。'
return
}
if (newPassword.value !== confirmNewPassword.value) {
@@ -145,7 +145,7 @@ async function savePassword() {
<div>
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
修改后下次登录需要使用新密码。建议使用至少 6 位且不易猜测的密码。
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
</p>
</div>
<div class="grid gap-3">
+11 -11
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
import { Settings } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
@@ -70,12 +70,12 @@ const pageTitle = computed(() => {
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
})
const profileSubtitle = computed(() => {
if (isOwnProfile.value) {
return '回看你的云图记录、收集进度和拍摄节奏。'
}
return '公开展示的云图记录会按时间顺序陈列在这里。'
})
// const profileSubtitle = computed(() => {
// if (isOwnProfile.value) {
// return '回看你的云图记录、收集进度和拍摄节奏。'
// }
// return '公开展示的云图记录会按时间顺序陈列在这里。'
// })
const totalShots = computed(() => clouds.value.length)
@@ -526,13 +526,13 @@ watch(selectedUploadDate, async newValue => {
</button>
</RouterLink>
</div>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
{{ profileSubtitle }}
</p>
</p> -->
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
<NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
<NTag v-else :bordered="false">公开展示</NTag>
<!-- <NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
<NTag v-else :bordered="false">公开展示</NTag> -->
</div>
</div>
</div>