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:
2026-05-22 21:04:49 +08:00
parent 599c8107d1
commit 9150a17097
15 changed files with 1410 additions and 50 deletions
+53 -8
View File
@@ -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>
+25 -11
View File
@@ -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"
+136
View File
@@ -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>