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
+19 -111
View File
@@ -1,23 +1,19 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { NAlert, NButton, NCard, NIcon, NProgress, NResult, NTag } from 'naive-ui'
import { NAlert, NButton, NCard, NIcon, NProgress, NResult } from 'naive-ui'
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
import { useRouter } from 'vue-router'
import { useCloudsStore } from '@/stores/clouds'
import { useAuthStore } from '@/stores/auth'
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
import { useUpload } from '@/composables/useUpload'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
const router = useRouter()
const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload()
const activeId = ref<string | null>(null)
const dragOver = ref(false)
const successMsg = ref(false)
const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges']>([])
const errorMsg = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const mapPickerOpen = ref(false)
@@ -87,10 +83,6 @@ function confirmMapPicker(payload: { latitude: number; longitude: number }) {
closeMapPicker()
}
function clearCoordinates() {
updateActiveCoordinates(null, null)
}
function updateActivePublicState(isPublic: boolean) {
if (!activeItem.value) return
activeItem.value.isHidden = !isPublic
@@ -112,43 +104,17 @@ async function handleSubmit() {
const result = await uploadAll()
if (result.ok) {
unlockedBadges.value = result.unlockedBadges
successMsg.value = true
} else {
errorMsg.value = '上传失败,请稍后重试'
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
}
}
function resetAfterSuccess() {
successMsg.value = false
unlockedBadges.value = []
errorMsg.value = ''
}
async function saveBadge(badge: NonNullable<typeof unlockedBadges.value[number]>) {
await downloadCloudBadgeCard({
cloudName: badge.cloudName,
cloudNameEn: badge.cloudNameEn,
unlockedAt: badge.unlockedAt,
username: authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户',
rarity: badge.rarity,
})
}
function rarityLabel(rarity: NonNullable<typeof unlockedBadges.value[number]>['rarity']) {
if (rarity === 'common') return '常见'
if (rarity === 'uncommon') return '少见'
return '罕见'
}
function formatUnlockedAt(iso: string) {
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function onLatInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (activeItem.value) {
@@ -168,6 +134,7 @@ function onLngInput(e: Event) {
}
function formatDatetimeLocal(iso: string): string {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
@@ -175,8 +142,8 @@ function formatDatetimeLocal(iso: string): string {
function onCapturedAtChange(e: Event) {
const val = (e.target as HTMLInputElement).value
if (activeItem.value && val) {
activeItem.value.capturedAt = new Date(val).toISOString()
if (activeItem.value) {
activeItem.value.capturedAt = val ? new Date(val).toISOString() : ''
if (activeItem.value.errors.capturedAt) {
delete activeItem.value.errors.capturedAt
}
@@ -186,9 +153,7 @@ function onCapturedAtChange(e: Event) {
function onCategoryChange(e: Event) {
const val = (e.target as HTMLSelectElement).value
if (!activeItem.value) return
if (val === 'other') {
activeItem.value.cloudCategoryId = 'other'
} else if (val === '') {
if (val === '') {
activeItem.value.cloudCategoryId = null
} else {
activeItem.value.cloudCategoryId = Number(val)
@@ -209,48 +174,21 @@ onUnmounted(() => {
<p class="oc-page-eyebrow">Cloud Upload</p>
<h1 class="oc-page-title">上传云图</h1>
<p class="oc-page-description">
批量整理你的云图记录。类别和拍摄时间必填,经纬度既可以手动输入,也可以在迷你地图里点选回填。
批量整理你的云图记录。类别和经纬度必填;拍摄时间可留空,也可以从图片 EXIF 自动读取。
</p>
</div>
</section>
<div class="oc-container oc-container--content oc-page-content">
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
<input ref="fileInput" type="file" accept="image/jpeg,image/png,image/webp" multiple class="hidden" @change="handleFileSelect" />
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
<div class="w-full max-w-5xl">
<NResult status="success" title="已提交审核" />
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<NCard
v-for="badge in unlockedBadges"
:key="badge.cloudTypeId"
class="oc-panel-card border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)]"
>
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white">
{{ badge.cloudName.slice(0, 1) }}
</div>
<div class="mt-5">
<div class="flex items-center gap-3">
<h3 class="text-2xl font-bold text-slate-900">{{ badge.cloudName }}</h3>
<NTag :bordered="false" type="warning">
{{ rarityLabel(badge.rarity) }}
</NTag>
</div>
<p class="mt-1 text-sm text-slate-500">{{ badge.cloudNameEn }}</p>
<p class="mt-4 text-sm text-slate-600">解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}</p>
</div>
<div class="mt-6 flex gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
</div>
</NCard>
</div>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="resetAfterSuccess">继续上传</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往图鉴</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往百科</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="router.push('/profile')">返回个人主页</NButton>
</div>
</div>
@@ -283,7 +221,7 @@ onUnmounted(() => {
>
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
<p class="text-sm text-slate-400">支持 JPG、PNG 格式,可一次选择多张</p>
<p class="text-sm text-slate-400">支持 JPEG、PNG、WebP,单张不超过 20MB,可一次选择多张</p>
</div>
<template v-else>
@@ -341,27 +279,16 @@ onUnmounted(() => {
>
<option value="" disabled>请选择</option>
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
{{ ct.name }}({{ ct.name_en }})
{{ ct.name }}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
</option>
<option value="other">其他</option>
</select>
<div v-if="activeItem.cloudCategoryId === 'other'" class="mt-2">
<input
v-model="activeItem.customCloudType"
type="text"
placeholder="输入云的类型"
@input="activeItem.errors = {}"
class="oc-field-control"
/>
</div>
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
<p v-if="activeItem.errors.file" class="oc-field-error">{{ activeItem.errors.file }}</p>
</div>
<!-- 拍摄时间 * -->
<!-- 拍摄时间 -->
<div>
<label class="oc-field-label">
拍摄时间 <span class="oc-field-required">*</span>
</label>
<label class="oc-field-label">拍摄时间</label>
<input
type="datetime-local"
:value="formatDatetimeLocal(activeItem.capturedAt)"
@@ -371,19 +298,9 @@ onUnmounted(() => {
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
</div>
<!-- 经纬度 -->
<!-- 经纬度 * -->
<div>
<div class="mb-1 flex items-center justify-between gap-3">
<label class="oc-field-label mb-0">经纬度</label>
<button
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
type="button"
@click="clearCoordinates"
class="oc-text-button oc-text-button--muted text-xs"
>
清空
</button>
</div>
<label class="oc-field-label">经纬度 <span class="oc-field-required">*</span></label>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<div>
<input
@@ -419,18 +336,7 @@ onUnmounted(() => {
</button>
</div>
</div>
<p class="mt-1 text-xs text-slate-400">选填,可手动输入,或点击右侧小地图图标选择</p>
</div>
<!-- 位置名称 -->
<div>
<label class="oc-field-label">位置名称</label>
<input
v-model="activeItem.locationName"
type="text"
placeholder="如:北京、成都"
class="oc-field-control"
/>
<p class="mt-1 text-xs text-slate-400">必填,可手动输入,或点击右侧地图图标选择</p>
</div>
<!-- 图片描述 -->
@@ -439,9 +345,11 @@ onUnmounted(() => {
<textarea
v-model="activeItem.description"
rows="3"
maxlength="128"
placeholder="描述一下这张图片..."
class="oc-field-control resize-none"
></textarea>
<p v-if="activeItem.errors.description" class="oc-field-error">{{ activeItem.errors.description }}</p>
</div>
<!-- 公开状态 -->