feat: add admin console and account settings
Implement the admin dashboard with review, user, and image management workflows. Add profile settings, password reset, pending upload defaults, community placeholder routing, Vercel SPA rewrites, refreshed header styling, and the OpenCloud color-system skill.
This commit is contained in:
@@ -11,7 +11,10 @@ const route = useRoute()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const resetMessage = ref('')
|
||||
const resetMode = ref(false)
|
||||
const loading = ref(false)
|
||||
const resetLoading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
@@ -26,6 +29,26 @@ async function handleLogin() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -61,7 +84,7 @@ async function handleLogin() {
|
||||
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
||||
</div>
|
||||
|
||||
<NForm @submit.prevent="handleLogin">
|
||||
<NForm @submit.prevent="resetMode ? handleSendResetEmail() : handleLogin()">
|
||||
<NFormItem label="邮箱">
|
||||
<NInput
|
||||
v-model:value="email"
|
||||
@@ -71,7 +94,7 @@ async function handleLogin() {
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="密码">
|
||||
<NFormItem v-if="!resetMode" label="密码">
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
@@ -82,25 +105,47 @@ async function handleLogin() {
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<p v-else class="mb-5 text-sm leading-6 text-slate-500">
|
||||
输入注册邮箱,我们会发送一封密码重置邮件。点击邮件链接后即可设置新密码。
|
||||
</p>
|
||||
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
</NAlert>
|
||||
|
||||
<NAlert v-if="resetMessage" type="success" class="mb-4">
|
||||
{{ resetMessage }}
|
||||
</NAlert>
|
||||
|
||||
<NButton
|
||||
attr-type="submit"
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="loading"
|
||||
:loading="resetMode ? resetLoading : loading"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
<template v-if="resetMode">
|
||||
{{ resetLoading ? '发送中...' : '发送重置邮件' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</template>
|
||||
</NButton>
|
||||
</NForm>
|
||||
|
||||
<p class="mt-6 text-sm text-slate-500">
|
||||
没有账号?
|
||||
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||
<p>
|
||||
没有账号?
|
||||
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
|
||||
@click="resetMode = !resetMode; error = ''; resetMessage = ''"
|
||||
>
|
||||
{{ resetMode ? '返回登录' : '忘记密码?' }}
|
||||
</button>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,19 +47,33 @@ async function handleRegister() {
|
||||
|
||||
<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_42%,#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">Observer Join</p>
|
||||
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||
加入天空探索者
|
||||
<span class="block text-sky-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,#eff6ff_0%,#ffffff_42%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
||||
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||
加入天空探索者
|
||||
<span class="block text-sky-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">Review</div>
|
||||
<div class="mt-2 text-2xl font-bold text-slate-900">Queue</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">Profile</div>
|
||||
<div class="mt-2 text-2xl font-bold text-slate-900">Journal</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)]">
|
||||
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NResult
|
||||
v-if="emailSent"
|
||||
status="success"
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const canSubmit = computed(() => password.value.length >= 6 && password.value === confirmPassword.value)
|
||||
|
||||
function getRecoveryTokens() {
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1))
|
||||
return {
|
||||
accessToken: hash.get('access_token'),
|
||||
refreshToken: hash.get('refresh_token'),
|
||||
type: hash.get('type'),
|
||||
error: hash.get('error'),
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
error.value = ''
|
||||
|
||||
if (password.value.length < 6) {
|
||||
error.value = '新密码至少需要 6 位。'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = '两次输入的密码不一致。'
|
||||
return
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, type, error: recoveryError } = getRecoveryTokens()
|
||||
if (recoveryError || type !== 'recovery' || !accessToken || !refreshToken) {
|
||||
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const resetClient = createClient(
|
||||
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
|
||||
|
||||
await resetClient.auth.signOut()
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
success.value = true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NResult
|
||||
v-if="success"
|
||||
status="success"
|
||||
title="密码已重置"
|
||||
description="现在可以使用新密码登录。"
|
||||
>
|
||||
<template #footer>
|
||||
<NButton type="primary" @click="router.push('/login')">返回登录</NButton>
|
||||
</template>
|
||||
</NResult>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-slate-500">请输入新的登录密码,提交后当前重置链接会失效。</p>
|
||||
</div>
|
||||
|
||||
<NForm @submit.prevent="handleResetPassword">
|
||||
<NFormItem label="新密码">
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="确认新密码">
|
||||
<NInput
|
||||
v-model:value="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
</NAlert>
|
||||
|
||||
<NButton
|
||||
attr-type="submit"
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:disabled="!canSubmit"
|
||||
:loading="loading"
|
||||
>
|
||||
保存新密码
|
||||
</NButton>
|
||||
</NForm>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user