feat: 适配新版后端 API 并接入邮箱验证 (#9)

## 概述
本分支对前端进行了大规模适配与升级,核心是对齐新版后端 API 接口,并接入邮箱验证与密码找回流程。

## 主要变更
- **API 适配**:对齐新版后端接口契约,重构 api 客户端与类型定义,移除前端臆造字段
- **认证流程**:接入邮箱验证(确认 / 重发确认)、密码找回(忘记密码 / 重置密码)全流程
- **管理后台**:前端对齐更新后的 API 契约
- **云图鉴**:展示云类型图标图片
- **UI 优化**:图片预览改为直角样式并调整关闭按钮

## 影响范围
- API 层:src/lib/api.ts、src/types/api.ts、src/types/database.ts
- 认证视图:src/views/auth/*
- 管理后台:src/views/admin/AdminView.vue
- 图鉴视图:src/views/encyclopedia/*
- 状态管理:src/stores/*

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-08-22 21:39:42 +08:00
parent cd70f89a28
commit 720ad29f06
34 changed files with 2472 additions and 2165 deletions
+62 -91
View File
@@ -1,44 +1,64 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { ApiError } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const route = useRoute()
const email = ref('')
const name = ref('')
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
const password = ref('')
const confirmPassword = ref('')
const username = ref('')
const error = ref('')
const loading = ref(false)
const emailSent = ref(false)
const registered = ref(false)
const emailDelivered = ref(true)
const registerConflict = ref(false)
const canResend = computed(() => registerConflict.value)
async function handleRegister() {
error.value = ''
registerConflict.value = false
const trimmedName = name.value.trim()
const trimmedEmail = email.value.trim()
if (!username.value.trim()) {
if (!trimmedName) {
error.value = '请输入用户名'
return
}
if (username.value.length > 20) {
error.value = '用户名不能超过20个字符'
if (trimmedName.length > 16) {
error.value = '用户名不能超过 16 个字符'
return
}
if (!trimmedEmail || trimmedEmail.length > 64) {
error.value = '请输入不超过 64 个字符的有效邮箱'
return
}
if (password.value.length < 8) {
error.value = '密码至少8位'
error.value = '密码至少需要 8 位'
return
}
if (password.value !== confirmPassword.value) {
error.value = '两次密码不一致'
error.value = '两次输入的密码不一致'
return
}
loading.value = true
try {
await authStore.register(email.value, password.value, username.value.trim())
emailSent.value = true
const response = await authStore.register(trimmedName, trimmedEmail, password.value)
email.value = trimmedEmail
emailDelivered.value = response.email_sent
registered.value = true
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
if (e instanceof ApiError && e.status === 409) {
registerConflict.value = true
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
} else {
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
}
} finally {
loading.value = false
}
@@ -55,40 +75,41 @@ 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> -->
</div>
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Review</div>
<div class="oc-stat-value">Queue</div>
<div class="oc-field-help">上传后进入审核队列</div>
<div class="oc-stat-label">Step 1</div>
<div class="oc-stat-value">Join</div>
<div class="oc-field-help">填写账号资料</div>
</div>
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Profile</div>
<div class="oc-stat-value">Journal</div>
<div class="oc-field-help">自动生成个人天空日志</div>
<div class="oc-stat-label">Step 2</div>
<div class="oc-stat-value">Verify</div>
<div class="oc-field-help">通过邮件确认邮箱</div>
</div>
</div>
</section>
<NCard class="oc-panel-card-xl min-w-0 h-full">
<NResult
v-if="emailSent"
status="success"
v-if="registered"
:status="emailDelivered ? 'success' : 'warning'"
title="确认你的邮箱"
description="确认邮件已经发送,请查收并点击链接完成注册。"
:description="emailDelivered
? '确认邮件已经发送,请查收并点击链接完成注册。'
: '账号已创建,但验证邮件暂未送达。请使用重发验证功能,不要重复注册。'"
>
<template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">
目标邮箱:
<span class="font-semibold text-slate-700">{{ email }}</span>
</p>
<RouterLink to="/login" class="no-underline">
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
</RouterLink>
<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">
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="no-underline">
<NButton type="default" class="oc-panel-button oc-panel-button--sky">重发验证邮件</NButton>
</RouterLink>
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
<NButton type="default" class="oc-panel-button oc-panel-button--teal">去登录</NButton>
</RouterLink>
</div>
</div>
</template>
</NResult>
@@ -97,86 +118,36 @@ async function handleRegister() {
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Create Account</div>
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
<p class="mt-2 text-sm text-slate-500">加入社区后即可开始记录、收集与展示云图。</p>
<p class="mt-2 text-sm text-slate-500">注册后,请先通过邮件确认邮箱再登录。</p>
</div>
<NForm @submit.prevent="handleRegister">
<NFormItem label="用户名">
<NInput
v-model:value="username"
type="text"
:input-props="{
autocomplete: 'username',
name: 'username',
required: true,
}"
placeholder="你的昵称"
maxlength="20"
/>
<NInput v-model:value="name" maxlength="16" show-count placeholder="最多 16 个字符" :input-props="{ autocomplete: 'username', name: 'username', required: true }" />
</NFormItem>
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'email',
inputmode: 'email',
name: 'email',
required: true,
}"
placeholder="your@email.com"
/>
<NInput v-model:value="email" maxlength="64" type="text" placeholder="your@email.com" :input-props="{ autocomplete: 'email', inputmode: 'email', name: 'email', required: true }" />
</NFormItem>
<NFormItem label="密码">
<NInput
v-model:value="password"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
placeholder="至少8位"
/>
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="至少 8 位" :input-props="{ autocomplete: 'new-password', name: 'new-password', required: true }" />
</NFormItem>
<NFormItem label="确认密码">
<NInput
v-model:value="confirmPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
placeholder="再次输入密码"
/>
<NInput v-model:value="confirmPassword" type="password" show-password-on="click" placeholder="再次输入密码" :input-props="{ autocomplete: 'new-password', name: 'confirm-new-password', required: true }" />
</NFormItem>
<NAlert v-if="error" type="error" class="mb-4">
{{ error }}
<div v-if="canResend" class="mt-2">
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="font-semibold text-rose-700 underline underline-offset-2">重发验证邮件</RouterLink>
</div>
</NAlert>
<NButton
attr-type="submit"
type="primary"
block
size="large"
class="oc-primary-button oc-primary-button--sky"
:loading="loading"
>
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--sky" :loading="loading">
{{ loading ? '注册中...' : '注册' }}
</NButton>
</NForm>
<p class="mt-6 text-sm text-slate-500">
已有账号?
<RouterLink to="/login" class="font-semibold text-sky-700 hover:text-sky-800">去登录</RouterLink>
</p>
<p class="mt-6 text-sm text-slate-500">已有账号? <RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">去登录</RouterLink></p>
</template>
</NCard>
</div>