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