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:
+19
-111
@@ -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>
|
||||
|
||||
<!-- 公开状态 -->
|
||||
|
||||
Reference in New Issue
Block a user