优化密码找回流程、管理后台批量操作和上传体验 #2
Generated
+39
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@supabase/supabase-js": "^2.106.1",
|
"@supabase/supabase-js": "^2.106.1",
|
||||||
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.44.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
@@ -952,6 +953,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vercel/speed-insights": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@vercel/speed-insights/-/speed-insights-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@sveltejs/kit": "^1 || ^2",
|
||||||
|
"next": ">= 13",
|
||||||
|
"nuxt": ">= 3",
|
||||||
|
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"svelte": ">= 4",
|
||||||
|
"vue": "^3",
|
||||||
|
"vue-router": "^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@sveltejs/kit": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nuxt": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue-router": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vicons/tabler": {
|
"node_modules/@vicons/tabler": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@vicons/tabler/-/tabler-0.13.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@vicons/tabler/-/tabler-0.13.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@supabase/supabase-js": "^2.106.1",
|
"@supabase/supabase-js": "^2.106.1",
|
||||||
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.44.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { NConfigProvider, NDialogProvider, NGlobalStyle, NMessageProvider, NNotificationProvider, type GlobalThemeOverrides } from 'naive-ui'
|
import { NConfigProvider, NDialogProvider, NGlobalStyle, NMessageProvider, NNotificationProvider, type GlobalThemeOverrides } from 'naive-ui'
|
||||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
import { Analytics } from "@vercel/analytics/vue"
|
import { Analytics } from "@vercel/analytics/vue"
|
||||||
|
import { SpeedInsights } from "@vercel/speed-insights/vue"
|
||||||
|
|
||||||
const themeOverrides: GlobalThemeOverrides = {
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
common: {
|
common: {
|
||||||
@@ -89,4 +90,5 @@ const themeOverrides: GlobalThemeOverrides = {
|
|||||||
</NDialogProvider>
|
</NDialogProvider>
|
||||||
</NConfigProvider>
|
</NConfigProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
|
<SpeedInsights />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import {
|
||||||
|
createClient,
|
||||||
|
type EmailOtpType,
|
||||||
|
type Session,
|
||||||
|
type SupabaseClient,
|
||||||
|
} from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||||
|
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||||
|
|
||||||
|
const knownEmailOtpTypes = new Set<EmailOtpType>([
|
||||||
|
'signup',
|
||||||
|
'invite',
|
||||||
|
'magiclink',
|
||||||
|
'recovery',
|
||||||
|
'email_change',
|
||||||
|
'email',
|
||||||
|
])
|
||||||
|
|
||||||
|
type AuthCallbackParams = {
|
||||||
|
accessToken: string | null
|
||||||
|
code: string | null
|
||||||
|
error: string | null
|
||||||
|
errorCode: string | null
|
||||||
|
errorDescription: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
tokenHash: string | null
|
||||||
|
type: EmailOtpType | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolveEmailAuthCallbackOptions = {
|
||||||
|
allowedTypes: EmailOtpType[]
|
||||||
|
client: SupabaseClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolveEmailAuthCallbackResult =
|
||||||
|
| {
|
||||||
|
ok: true
|
||||||
|
session: Session | null
|
||||||
|
type: EmailOtpType | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEmailOtpType(value: string | null): EmailOtpType | null {
|
||||||
|
if (!value || !knownEmailOtpTypes.has(value as EmailOtpType)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as EmailOtpType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAuthCallbackParams(): AuthCallbackParams {
|
||||||
|
const search = new URLSearchParams(window.location.search)
|
||||||
|
const hash = new URLSearchParams(window.location.hash.slice(1))
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: search.get('code'),
|
||||||
|
tokenHash: search.get('token_hash') ?? hash.get('token_hash'),
|
||||||
|
accessToken: hash.get('access_token'),
|
||||||
|
refreshToken: hash.get('refresh_token'),
|
||||||
|
type: parseEmailOtpType(search.get('type') ?? hash.get('type')),
|
||||||
|
error: search.get('error') ?? hash.get('error'),
|
||||||
|
errorCode: search.get('error_code') ?? hash.get('error_code'),
|
||||||
|
errorDescription: search.get('error_description') ?? hash.get('error_description'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthCallbackUrl() {
|
||||||
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDetachedAuthClient() {
|
||||||
|
return createClient(supabaseUrl, supabaseKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
detectSessionInUrl: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveEmailAuthCallback(
|
||||||
|
options: ResolveEmailAuthCallbackOptions,
|
||||||
|
): Promise<ResolveEmailAuthCallbackResult> {
|
||||||
|
const { client, allowedTypes } = options
|
||||||
|
const params = readAuthCallbackParams()
|
||||||
|
const allowedTypeSet = new Set(allowedTypes)
|
||||||
|
|
||||||
|
if (params.error || params.errorCode || params.errorDescription) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: params.errorDescription || '链接无效或已过期。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let session: Session | null = null
|
||||||
|
|
||||||
|
if (params.tokenHash && params.type && allowedTypeSet.has(params.type)) {
|
||||||
|
const { data, error } = await client.auth.verifyOtp({
|
||||||
|
token_hash: params.tokenHash,
|
||||||
|
type: params.type,
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
session = data.session
|
||||||
|
} else if (params.code) {
|
||||||
|
const { data, error } = await client.auth.exchangeCodeForSession(params.code)
|
||||||
|
if (error) throw error
|
||||||
|
session = data.session
|
||||||
|
} else if (
|
||||||
|
params.accessToken
|
||||||
|
&& params.refreshToken
|
||||||
|
&& params.type
|
||||||
|
&& allowedTypeSet.has(params.type)
|
||||||
|
) {
|
||||||
|
const { data, error } = await client.auth.setSession({
|
||||||
|
access_token: params.accessToken,
|
||||||
|
refresh_token: params.refreshToken,
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
session = data.session
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: '链接无效或已过期。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
const { data, error } = await client.auth.getSession()
|
||||||
|
if (error) throw error
|
||||||
|
session = data.session
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAuthCallbackUrl()
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
session,
|
||||||
|
type: params.type,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '链接无效或已过期。'
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -7,4 +7,9 @@ if (!supabaseUrl || !supabaseKey) {
|
|||||||
throw new Error('Missing Supabase environment variables')
|
throw new Error('Missing Supabase environment variables')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseKey)
|
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||||
|
auth: {
|
||||||
|
// Email confirmation and password recovery are handled by route components.
|
||||||
|
detectSessionInUrl: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
+18
-1
@@ -27,6 +27,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/auth/RegisterView.vue'),
|
component: () => import('@/views/auth/RegisterView.vue'),
|
||||||
meta: { title: '注册', noindex: true },
|
meta: { title: '注册', noindex: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/forgot-password',
|
||||||
|
name: 'forgot-password',
|
||||||
|
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||||
|
meta: { title: '忘记密码', noindex: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/auth/confirm',
|
path: '/auth/confirm',
|
||||||
name: 'auth-confirm',
|
name: 'auth-confirm',
|
||||||
@@ -114,6 +120,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/system/ForbiddenView.vue'),
|
component: () => import('@/views/system/ForbiddenView.vue'),
|
||||||
meta: { title: '无权访问', noindex: true },
|
meta: { title: '无权访问', noindex: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/401',
|
||||||
|
name: 'auth-required',
|
||||||
|
component: () => import('@/views/system/AuthRequiredView.vue'),
|
||||||
|
meta: { title: '需要登录', noindex: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'not-found',
|
name: 'not-found',
|
||||||
@@ -130,7 +142,12 @@ router.beforeEach((to, _from, next) => {
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
next({
|
||||||
|
name: 'auth-required',
|
||||||
|
query: {
|
||||||
|
redirect: to.fullPath,
|
||||||
|
},
|
||||||
|
})
|
||||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||||
next({ name: 'forbidden' })
|
next({ name: 'forbidden' })
|
||||||
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
||||||
|
|||||||
+6
-1
@@ -118,7 +118,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||||
})
|
})
|
||||||
if (error) throw error
|
if (error) {
|
||||||
|
if (error.message.includes('rate limit')) {
|
||||||
|
throw new Error('发送过于频繁,请稍后再试。')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
|
|||||||
+144
-15
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||||
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
|
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
@@ -63,6 +63,7 @@ const dashboardStats = ref<DashboardStats>({
|
|||||||
const users = ref<Profile[]>([])
|
const users = ref<Profile[]>([])
|
||||||
const images = ref<AdminCloud[]>([])
|
const images = ref<AdminCloud[]>([])
|
||||||
const selectedReviewIds = ref<Set<string>>(new Set())
|
const selectedReviewIds = ref<Set<string>>(new Set())
|
||||||
|
const selectedImageIds = ref<Set<string>>(new Set())
|
||||||
const imageFilter = ref<ImageFilter>('all')
|
const imageFilter = ref<ImageFilter>('all')
|
||||||
const selectedImage = ref<AdminCloud | null>(null)
|
const selectedImage = ref<AdminCloud | null>(null)
|
||||||
|
|
||||||
@@ -90,6 +91,11 @@ const filteredImages = computed(() => {
|
|||||||
if (imageFilter.value === 'all') return images.value
|
if (imageFilter.value === 'all') return images.value
|
||||||
return images.value.filter(item => item.status === imageFilter.value)
|
return images.value.filter(item => item.status === imageFilter.value)
|
||||||
})
|
})
|
||||||
|
const selectedManagedCount = computed(() => selectedImageIds.value.size)
|
||||||
|
|
||||||
|
watch(imageFilter, () => {
|
||||||
|
setSelectedImageIds([])
|
||||||
|
})
|
||||||
|
|
||||||
const cloudTypeStats = computed(() => {
|
const cloudTypeStats = computed(() => {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
@@ -249,6 +255,7 @@ async function fetchImages() {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
|
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
|
||||||
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
|
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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAdminData() {
|
async function loadAdminData() {
|
||||||
@@ -290,6 +297,28 @@ function toggleAllPendingSelection() {
|
|||||||
setSelectedReviewIds(pendingImages.value.map(item => item.id))
|
setSelectedReviewIds(pendingImages.value.map(item => item.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSelectedImageIds(ids: Iterable<string>) {
|
||||||
|
selectedImageIds.value = new Set(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleImageSelection(cloudId: string) {
|
||||||
|
const next = new Set(selectedImageIds.value)
|
||||||
|
if (next.has(cloudId)) {
|
||||||
|
next.delete(cloudId)
|
||||||
|
} else {
|
||||||
|
next.add(cloudId)
|
||||||
|
}
|
||||||
|
setSelectedImageIds(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllFilteredImageSelection() {
|
||||||
|
if (selectedImageIds.value.size === filteredImages.value.length) {
|
||||||
|
setSelectedImageIds([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedImageIds(filteredImages.value.map(item => item.id))
|
||||||
|
}
|
||||||
|
|
||||||
function patchImages(ids: string[], patch: Partial<AdminCloud>) {
|
function patchImages(ids: string[], patch: Partial<AdminCloud>) {
|
||||||
const idSet = new Set(ids)
|
const idSet = new Set(ids)
|
||||||
images.value = images.value.map(item => (idSet.has(item.id) ? { ...item, ...patch } : item))
|
images.value = images.value.map(item => (idSet.has(item.id) ? { ...item, ...patch } : item))
|
||||||
@@ -321,6 +350,7 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
|||||||
|
|
||||||
patchImages(ids, { status })
|
patchImages(ids, { status })
|
||||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
|
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
|
||||||
|
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
|
||||||
await fetchStats()
|
await fetchStats()
|
||||||
message.success(`已${status === 'approved' ? '通过' : '拒绝'} ${ids.length} 张图片`)
|
message.success(`已${status === 'approved' ? '通过' : '拒绝'} ${ids.length} 张图片`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -332,26 +362,28 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleImageVisibility(cloud: AdminCloud) {
|
async function updateImageVisibility(ids: string[], isHidden: boolean) {
|
||||||
|
if (!ids.length) return
|
||||||
|
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextHidden = !cloud.is_hidden
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('clouds')
|
.from('clouds')
|
||||||
.update({ is_hidden: nextHidden })
|
.update({ is_hidden: isHidden })
|
||||||
.eq('id', cloud.id)
|
.in('id', ids)
|
||||||
.select('id')
|
.select('id')
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
if (!data?.length) {
|
if ((data || []).length !== ids.length) {
|
||||||
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||||
}
|
}
|
||||||
|
|
||||||
patchImages([cloud.id], { is_hidden: nextHidden })
|
patchImages(ids, { is_hidden: isHidden })
|
||||||
|
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
|
||||||
await fetchStats()
|
await fetchStats()
|
||||||
message.success(nextHidden ? '图片已设为隐藏' : '图片已恢复公开')
|
message.success(isHidden ? `已隐藏 ${ids.length} 张图片` : `已公开 ${ids.length} 张图片`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '可见性更新失败')
|
const text = getErrorMessage(error, '可见性更新失败')
|
||||||
loadError.value = text
|
loadError.value = text
|
||||||
@@ -361,20 +393,45 @@ async function toggleImageVisibility(cloud: AdminCloud) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteImage(cloud: AdminCloud) {
|
async function toggleImageVisibility(cloud: AdminCloud) {
|
||||||
const confirmed = window.confirm(`确定删除 ${cloud.cloudTypeName} 这张图片吗?删除后无法在页面中恢复。`)
|
await updateImageVisibility([cloud.id], !cloud.is_hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteImages(clouds: AdminCloud[]) {
|
||||||
|
if (!clouds.length) return
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
clouds.length === 1
|
||||||
|
? `确定删除 ${clouds[0].cloudTypeName} 这张图片吗?删除后无法在页面中恢复。`
|
||||||
|
: `确定删除选中的 ${clouds.length} 张图片吗?删除后无法在页面中恢复。`,
|
||||||
|
)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await profileStore.deleteClouds(cloud.user_id, [cloud.id])
|
const idsToDelete = clouds.map(cloud => cloud.id)
|
||||||
images.value = images.value.filter(item => item.id !== cloud.id)
|
const cloudsByUser = new Map<string, string[]>()
|
||||||
if (selectedImage.value?.id === cloud.id) selectedImage.value = null
|
|
||||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => id !== cloud.id))
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
|
||||||
|
if (selectedImage.value && idsToDelete.includes(selectedImage.value.id)) {
|
||||||
|
selectedImage.value = null
|
||||||
|
}
|
||||||
|
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !idsToDelete.includes(id)))
|
||||||
|
setSelectedImageIds([...selectedImageIds.value].filter(id => !idsToDelete.includes(id)))
|
||||||
await fetchStats()
|
await fetchStats()
|
||||||
message.success('图片已删除')
|
message.success(clouds.length === 1 ? '图片已删除' : `已删除 ${clouds.length} 张图片`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '图片删除失败')
|
const text = getErrorMessage(error, '图片删除失败')
|
||||||
loadError.value = text
|
loadError.value = text
|
||||||
@@ -384,6 +441,10 @@ async function deleteImage(cloud: AdminCloud) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteImage(cloud: AdminCloud) {
|
||||||
|
await deleteImages([cloud])
|
||||||
|
}
|
||||||
|
|
||||||
async function updateUserRole(user: Profile, role: Profile['role']) {
|
async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||||
if (user.id === authStore.user?.id && role !== 'admin') {
|
if (user.id === authStore.user?.id && role !== 'admin') {
|
||||||
message.warning('不能移除自己的管理员权限')
|
message.warning('不能移除自己的管理员权限')
|
||||||
@@ -702,6 +763,7 @@ onMounted(loadAdminData)
|
|||||||
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
|
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
|
<p class="mt-1 text-sm text-slate-500">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
<select
|
<select
|
||||||
v-model="imageFilter"
|
v-model="imageFilter"
|
||||||
class="border border-slate-300 bg-white px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
class="border border-slate-300 bg-white px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
@@ -711,6 +773,65 @@ onMounted(loadAdminData)
|
|||||||
<option value="approved">已通过</option>
|
<option value="approved">已通过</option>
|
||||||
<option value="rejected">已拒绝</option>
|
<option value="rejected">已拒绝</option>
|
||||||
</select>
|
</select>
|
||||||
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllFilteredImageSelection">
|
||||||
|
{{ selectedManagedCount === filteredImages.length && filteredImages.length ? '取消全选' : '全选当前列表' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
:disabled="!selectedManagedCount"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateCloudStatus(Array.from(selectedImageIds), 'approved')"
|
||||||
|
>
|
||||||
|
批量通过 {{ selectedManagedCount || '' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--danger"
|
||||||
|
:disabled="!selectedManagedCount"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateCloudStatus(Array.from(selectedImageIds), 'rejected')"
|
||||||
|
>
|
||||||
|
批量拒绝 {{ selectedManagedCount || '' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--amber"
|
||||||
|
:disabled="!selectedManagedCount"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateImageVisibility(Array.from(selectedImageIds), true)"
|
||||||
|
>
|
||||||
|
批量隐藏 {{ selectedManagedCount || '' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
:disabled="!selectedManagedCount"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateImageVisibility(Array.from(selectedImageIds), false)"
|
||||||
|
>
|
||||||
|
批量公开 {{ selectedManagedCount || '' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--danger"
|
||||||
|
:disabled="!selectedManagedCount"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="deleteImages(filteredImages.filter(item => selectedImageIds.has(item.id)))"
|
||||||
|
>
|
||||||
|
批量删除 {{ selectedManagedCount || '' }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="filteredImages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div v-if="filteredImages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
@@ -724,9 +845,17 @@ onMounted(loadAdminData)
|
|||||||
<p class="truncate font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
|
<p class="truncate font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
|
||||||
<p class="mt-1 truncate text-sm text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</p>
|
<p class="mt-1 truncate text-sm text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
|
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
|
||||||
{{ statusMeta[item.status].label }}
|
{{ statusMeta[item.status].label }}
|
||||||
</NTag>
|
</NTag>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-5 w-5"
|
||||||
|
:checked="selectedImageIds.has(item.id)"
|
||||||
|
@change="toggleImageSelection(item.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看</NButton>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看</NButton>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
||||||
import { createClient } from '@supabase/supabase-js'
|
import { createDetachedAuthClient, resolveEmailAuthCallback } from '@/lib/authEmail'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -22,43 +22,20 @@ function startCountdown() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const hash = window.location.hash.slice(1)
|
const confirmClient = createDetachedAuthClient()
|
||||||
const params = new URLSearchParams(hash)
|
const result = await resolveEmailAuthCallback({
|
||||||
const accessToken = params.get('access_token')
|
client: confirmClient,
|
||||||
const refreshToken = params.get('refresh_token')
|
allowedTypes: ['signup', 'email'],
|
||||||
const type = params.get('type')
|
|
||||||
const error = params.get('error')
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
state.value = 'failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'signup' && accessToken && refreshToken) {
|
|
||||||
const confirmClient = createClient(
|
|
||||||
import.meta.env.VITE_SUPABASE_URL,
|
|
||||||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
|
|
||||||
)
|
|
||||||
|
|
||||||
const { error: setSessionError } = await confirmClient.auth.setSession({
|
|
||||||
access_token: accessToken,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (setSessionError) {
|
if (!result.ok) {
|
||||||
state.value = 'failed'
|
state.value = 'failed'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await confirmClient.auth.signOut()
|
await confirmClient.auth.signOut()
|
||||||
|
|
||||||
window.history.replaceState(null, '', window.location.pathname)
|
|
||||||
state.value = 'success'
|
state.value = 'success'
|
||||||
startCountdown()
|
startCountdown()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.value = 'failed'
|
|
||||||
} catch {
|
} catch {
|
||||||
state.value = 'failed'
|
state.value = 'failed'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const cooldown = ref(0)
|
||||||
|
let cooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const resendButtonLabel = computed(() => {
|
||||||
|
if (loading.value) return '发送中...'
|
||||||
|
if (cooldown.value > 0) return `重新发送(${cooldown.value}s)`
|
||||||
|
return '重新发送'
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearCooldownTimer() {
|
||||||
|
if (!cooldownTimer) return
|
||||||
|
clearInterval(cooldownTimer)
|
||||||
|
cooldownTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCooldown() {
|
||||||
|
clearCooldownTimer()
|
||||||
|
cooldown.value = 60
|
||||||
|
cooldownTimer = setInterval(() => {
|
||||||
|
cooldown.value--
|
||||||
|
if (cooldown.value <= 0) {
|
||||||
|
cooldown.value = 0
|
||||||
|
clearCooldownTimer()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendResetEmail() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (!email.value.trim()) {
|
||||||
|
error.value = '请输入需要找回密码的邮箱。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.sendPasswordReset(email.value.trim())
|
||||||
|
submitted.value = true
|
||||||
|
startCooldown()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试。'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendResetEmail() {
|
||||||
|
if (cooldown.value > 0 || loading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleSendResetEmail()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearCooldownTimer()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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.05fr_0.95fr] lg:items-center">
|
||||||
|
<section class="border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
|
||||||
|
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||||
|
重新拿回你的
|
||||||
|
<span class="block text-sky-700">OpenCloud 账号</span>
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||||
|
输入注册邮箱后,我们会发送一封密码重置邮件。打开邮件中的链接,即可进入新的密码设置页面。
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Step 1</div>
|
||||||
|
<div class="mt-2 text-lg font-bold text-slate-900">发送邮件</div>
|
||||||
|
<div class="mt-1 text-sm text-slate-500">使用注册邮箱请求重置链接</div>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Step 2</div>
|
||||||
|
<div class="mt-2 text-lg font-bold text-slate-900">设置新密码</div>
|
||||||
|
<div class="mt-1 text-sm text-slate-500">在专用页面完成新密码更新</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<NResult
|
||||||
|
v-if="submitted"
|
||||||
|
status="success"
|
||||||
|
title="重置邮件已发送"
|
||||||
|
description="如果该邮箱已注册,请查收邮件中的重置链接。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
目标邮箱:
|
||||||
|
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
|
:disabled="cooldown > 0"
|
||||||
|
:loading="loading"
|
||||||
|
@click="resendResetEmail"
|
||||||
|
>
|
||||||
|
{{ resendButtonLabel }}
|
||||||
|
</NButton>
|
||||||
|
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
|
||||||
|
<NButton type="default" class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Reset Request</div>
|
||||||
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">输入你的注册邮箱,我们会发送一封密码重置邮件。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleSendResetEmail">
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--sky"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? '发送中...' : '发送重置邮件' }}
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||||
|
<p>如果你已经记起密码,可以直接返回登录。</p>
|
||||||
|
<RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">
|
||||||
|
返回登录
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -8,13 +8,10 @@ const authStore = useAuthStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const email = ref('')
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const resetMessage = ref('')
|
|
||||||
const resetMode = ref(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const resetLoading = ref(false)
|
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -29,26 +26,6 @@ async function handleLogin() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendResetEmail() {
|
|
||||||
error.value = ''
|
|
||||||
resetMessage.value = ''
|
|
||||||
|
|
||||||
if (!email.value) {
|
|
||||||
error.value = '请输入需要重置密码的邮箱。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetLoading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.sendPasswordReset(email.value)
|
|
||||||
resetMessage.value = '重置密码邮件已发送,请查收邮箱。'
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试'
|
|
||||||
} finally {
|
|
||||||
resetLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -84,7 +61,7 @@ async function handleSendResetEmail() {
|
|||||||
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NForm @submit.prevent="resetMode ? handleSendResetEmail() : handleLogin()">
|
<NForm @submit.prevent="handleLogin">
|
||||||
<NFormItem label="邮箱">
|
<NFormItem label="邮箱">
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="email"
|
v-model:value="email"
|
||||||
@@ -94,7 +71,7 @@ async function handleSendResetEmail() {
|
|||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem v-if="!resetMode" label="密码">
|
<NFormItem label="密码">
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="password"
|
v-model:value="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -105,32 +82,19 @@ async function handleSendResetEmail() {
|
|||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<p v-else class="mb-5 text-sm leading-6 text-slate-500">
|
|
||||||
输入注册邮箱,我们会发送一封密码重置邮件。点击邮件链接后即可设置新密码。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<NAlert v-if="resetMessage" type="success" class="mb-4">
|
|
||||||
{{ resetMessage }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NButton
|
<NButton
|
||||||
attr-type="submit"
|
attr-type="submit"
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
size="large"
|
size="large"
|
||||||
class="oc-primary-button oc-primary-button--teal"
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
:loading="resetMode ? resetLoading : loading"
|
:loading="loading"
|
||||||
>
|
>
|
||||||
<template v-if="resetMode">
|
|
||||||
{{ resetLoading ? '发送中...' : '发送重置邮件' }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ loading ? '登录中...' : '登录' }}
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
</template>
|
|
||||||
</NButton>
|
</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
|
|
||||||
@@ -139,13 +103,12 @@ async function handleSendResetEmail() {
|
|||||||
没有账号?
|
没有账号?
|
||||||
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
||||||
</p>
|
</p>
|
||||||
<button
|
<RouterLink
|
||||||
type="button"
|
:to="email ? { path: '/forgot-password', query: { email } } : { path: '/forgot-password' }"
|
||||||
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
|
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
|
||||||
@click="resetMode = !resetMode; error = ''; resetMessage = ''"
|
|
||||||
>
|
>
|
||||||
{{ resetMode ? '返回登录' : '忘记密码?' }}
|
忘记密码?
|
||||||
</button>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,72 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
|
||||||
import { createClient } from '@supabase/supabase-js'
|
import { resolveEmailAuthCallback } from '@/lib/authEmail'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const success = ref(false)
|
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const countdown = ref(5)
|
||||||
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const canSubmit = computed(() => password.value.length >= 6 && password.value === confirmPassword.value)
|
const canSubmit = computed(() =>
|
||||||
|
state.value === 'ready' && password.value.length >= 6 && password.value === confirmPassword.value,
|
||||||
|
)
|
||||||
|
|
||||||
function getRecoveryTokens() {
|
function showInvalidRecoveryLink() {
|
||||||
const hash = new URLSearchParams(window.location.hash.slice(1))
|
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
|
||||||
return {
|
state.value = 'invalid'
|
||||||
accessToken: hash.get('access_token'),
|
}
|
||||||
refreshToken: hash.get('refresh_token'),
|
|
||||||
type: hash.get('type'),
|
function getResetErrorMessage(value: unknown) {
|
||||||
error: hash.get('error'),
|
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')
|
||||||
|
) {
|
||||||
|
return '密码重置链接无效或已过期,请重新发送邮件。'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.includes('Password should be at least')) {
|
||||||
|
return '新密码至少需要 6 位。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return message || '密码重置失败,请稍后重试。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}, 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'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value = 'ready'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResetPassword() {
|
async function handleResetPassword() {
|
||||||
@@ -36,37 +81,38 @@ async function handleResetPassword() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken, type, error: recoveryError } = getRecoveryTokens()
|
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
|
||||||
if (recoveryError || type !== 'recovery' || !accessToken || !refreshToken) {
|
if (sessionError || !session?.user) {
|
||||||
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
|
showInvalidRecoveryLink()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const resetClient = createClient(
|
const { error: updateError } = await supabase.auth.updateUser({ password: password.value })
|
||||||
import.meta.env.VITE_SUPABASE_URL,
|
|
||||||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
|
|
||||||
)
|
|
||||||
|
|
||||||
const { error: sessionError } = await resetClient.auth.setSession({
|
|
||||||
access_token: accessToken,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
})
|
|
||||||
if (sessionError) throw sessionError
|
|
||||||
|
|
||||||
const { error: updateError } = await resetClient.auth.updateUser({ password: password.value })
|
|
||||||
if (updateError) throw updateError
|
if (updateError) throw updateError
|
||||||
|
|
||||||
await resetClient.auth.signOut()
|
|
||||||
window.history.replaceState(null, '', window.location.pathname)
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
success.value = true
|
countdown.value = 5
|
||||||
|
state.value = 'success'
|
||||||
|
startCountdown()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试。'
|
error.value = getResetErrorMessage(e)
|
||||||
|
if (error.value === '密码重置链接无效或已过期,请重新发送邮件。') {
|
||||||
|
state.value = 'invalid'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void initializeRecoverySession()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -74,21 +120,38 @@ async function handleResetPassword() {
|
|||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<NResult
|
<NResult
|
||||||
v-if="success"
|
v-if="state === 'success'"
|
||||||
status="success"
|
status="success"
|
||||||
title="密码已重置"
|
title="密码已重置"
|
||||||
description="现在可以使用新密码登录。"
|
description="你已使用新密码完成更新,正在返回地图页。"
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">返回登录</NButton>
|
<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>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else-if="state === 'checking'">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<NSpin size="large" />
|
||||||
|
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证重置链接...</h1>
|
||||||
|
<p class="mt-2 text-slate-500">请稍候</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
||||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
||||||
<p class="mt-2 text-sm text-slate-500">请输入新的登录密码,提交后当前重置链接会失效。</p>
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
{{
|
||||||
|
state === 'invalid'
|
||||||
|
? '当前重置链接不可用,请重新发送密码重置邮件。'
|
||||||
|
: '请输入新的登录密码,提交后当前重置链接会失效。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NForm @submit.prevent="handleResetPassword">
|
<NForm @submit.prevent="handleResetPassword">
|
||||||
@@ -118,6 +181,14 @@ async function handleResetPassword() {
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="state === 'invalid'"
|
||||||
|
to="/forgot-password"
|
||||||
|
class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800"
|
||||||
|
>
|
||||||
|
返回重新发送重置邮件
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
<NButton
|
<NButton
|
||||||
attr-type="submit"
|
attr-type="submit"
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
|||||||
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { loadAMap } from '@/lib/amap'
|
import { loadAMap } from '@/lib/amap'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { NIcon } from 'naive-ui'
|
import { NIcon } from 'naive-ui'
|
||||||
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ interface CloudMarkerData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapEl = ref<HTMLDivElement>()
|
const mapEl = ref<HTMLDivElement>()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const previewCloud = ref<CloudMarkerData | null>(null)
|
const previewCloud = ref<CloudMarkerData | null>(null)
|
||||||
const satelliteOn = ref(false)
|
const satelliteOn = ref(false)
|
||||||
const statusText = ref('加载中...')
|
const statusText = ref('加载中...')
|
||||||
@@ -70,7 +72,6 @@ const archiveTitle = computed(() => {
|
|||||||
if (mapMode.value === 'realtime') return '实时'
|
if (mapMode.value === 'realtime') return '实时'
|
||||||
return archiveKind.value === 'day' ? archiveDay.value : archiveMonth.value
|
return archiveKind.value === 'day' ? archiveDay.value : archiveMonth.value
|
||||||
})
|
})
|
||||||
|
|
||||||
function getMinuteOfDay(date: Date) {
|
function getMinuteOfDay(date: Date) {
|
||||||
return date.getHours() * 60 + date.getMinutes()
|
return date.getHours() * 60 + date.getMinutes()
|
||||||
}
|
}
|
||||||
@@ -344,6 +345,7 @@ function toggleSat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openQuickUpload() {
|
function openQuickUpload() {
|
||||||
|
if (authStore.loading || !authStore.isLoggedIn) return
|
||||||
quickUploadOpen.value = true
|
quickUploadOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,9 +448,11 @@ onUnmounted(() => {
|
|||||||
<div class="absolute bottom-6 right-7 z-20 flex w-10 flex-col items-center gap-2">
|
<div class="absolute bottom-6 right-7 z-20 flex w-10 flex-col items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50"
|
class="w-10 h-10 rounded-lg shadow-md flex items-center justify-center"
|
||||||
title="快速上传图片"
|
:class="authStore.loading || !authStore.isLoggedIn ? 'cursor-not-allowed bg-slate-100 text-slate-300 shadow-none' : 'bg-white hover:bg-gray-50'"
|
||||||
|
:title="authStore.isLoggedIn ? '快速上传图片' : '请先登录后上传图片'"
|
||||||
aria-label="快速上传图片"
|
aria-label="快速上传图片"
|
||||||
|
:disabled="authStore.loading || !authStore.isLoggedIn"
|
||||||
@click="openQuickUpload"
|
@click="openQuickUpload"
|
||||||
>
|
>
|
||||||
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NButton } from 'naive-ui'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const redirect = computed(() => {
|
||||||
|
const value = route.query.redirect
|
||||||
|
return typeof value === 'string' && value.startsWith('/') ? value : '/'
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (redirect.value === '/upload') {
|
||||||
|
return '上传前先登录'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '需要先登录'
|
||||||
|
})
|
||||||
|
|
||||||
|
const description = computed(() => {
|
||||||
|
if (redirect.value === '/upload') {
|
||||||
|
return '上传云图、使用地图快捷上传都需要先登录账号。登录后再回来,这里的上传入口就会开放。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '这个页面需要先登录才能继续访问。登录成功后可以返回刚才的目标页面继续操作。'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-10">
|
||||||
|
<div class="w-full max-w-2xl border border-slate-200 bg-white px-8 py-14 text-center shadow-sm">
|
||||||
|
<div class="text-7xl leading-none">🔐</div>
|
||||||
|
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-sky-600">401 Login Required</p>
|
||||||
|
<h1 class="mt-3 text-4xl font-bold text-slate-900">{{ title }}</h1>
|
||||||
|
<p class="mx-auto mt-4 max-w-xl text-sm leading-7 text-slate-600">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<RouterLink :to="{ path: '/login', query: redirect !== '/' ? { redirect } : undefined }">
|
||||||
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky">前往登录</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/">
|
||||||
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">返回地图</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -218,18 +218,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
||||||
<div class="w-full max-w-5xl">
|
<div class="w-full max-w-5xl">
|
||||||
<NResult status="success" title="已提交审核" class="border border-slate-200 bg-white shadow-sm">
|
<NResult status="success" title="已提交审核" />
|
||||||
<template #default>
|
|
||||||
<p class="text-slate-500">
|
|
||||||
<template v-if="unlockedBadges.length">
|
|
||||||
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章。图片审核通过后会出现在画廊和地图中。
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
这批云图已进入待审核队列,审核通过后会出现在画廊和地图中。
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
|
|
||||||
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<NCard
|
<NCard
|
||||||
|
|||||||
Reference in New Issue
Block a user