Files
opencloud/src/views/upload/UploadView.vue
T
Mplan 720ad29f06 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
2026-08-22 21:39:42 +08:00

404 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
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 { useUpload } from '@/composables/useUpload'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
const router = useRouter()
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 errorMsg = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const mapPickerOpen = ref(false)
const activeItem = computed(() => {
if (!activeId.value) return null
return items.value.find(i => i.id === activeId.value) ?? null
})
watch(() => items.value.length, () => {
if (items.value.length > 0 && !activeId.value) {
activeId.value = items.value[0].id
}
if (items.value.length === 0) {
activeId.value = null
}
})
function selectItem(id: string) {
activeId.value = id
}
function handleDrop(e: DragEvent) {
dragOver.value = false
if (e.dataTransfer?.files) {
addFiles(Array.from(e.dataTransfer.files))
}
}
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
addFiles(Array.from(target.files))
}
target.value = ''
}
function handleRemove(id: string) {
removeItem(id)
if (activeId.value === id) {
activeId.value = items.value[0]?.id ?? null
}
}
function updateActiveCoordinates(latitude: number | null, longitude: number | null) {
if (!activeItem.value) return
activeItem.value.latitude = latitude
activeItem.value.longitude = longitude
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
}
async function openMapPicker() {
if (!activeItem.value) return
mapPickerOpen.value = true
}
function closeMapPicker() {
mapPickerOpen.value = false
}
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
updateActiveCoordinates(payload.latitude, payload.longitude)
closeMapPicker()
}
function updateActivePublicState(isPublic: boolean) {
if (!activeItem.value) return
activeItem.value.isHidden = !isPublic
}
async function handleSubmit() {
errorMsg.value = ''
const allValid = validateAll()
if (!allValid) {
const firstInvalid = items.value.find(i => Object.keys(i.errors).length > 0)
if (firstInvalid) {
activeId.value = firstInvalid.id
await nextTick()
const el = document.getElementById(`field-${firstInvalid.id}-cloudCategory`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return
}
const result = await uploadAll()
if (result.ok) {
successMsg.value = true
} else {
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
}
}
function resetAfterSuccess() {
successMsg.value = false
errorMsg.value = ''
}
function onLatInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (activeItem.value) {
activeItem.value.latitude = isNaN(val) ? null : val
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
}
}
function onLngInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (activeItem.value) {
activeItem.value.longitude = isNaN(val) ? null : val
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
}
}
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())}`
}
function onCapturedAtChange(e: Event) {
const val = (e.target as HTMLInputElement).value
if (activeItem.value) {
activeItem.value.capturedAt = val ? new Date(val).toISOString() : ''
if (activeItem.value.errors.capturedAt) {
delete activeItem.value.errors.capturedAt
}
}
}
function onCategoryChange(e: Event) {
const val = (e.target as HTMLSelectElement).value
if (!activeItem.value) return
if (val === '') {
activeItem.value.cloudCategoryId = null
} else {
activeItem.value.cloudCategoryId = Number(val)
}
activeItem.value.errors = {}
}
onMounted(() => { cloudsStore.fetchCloudTypes() })
onUnmounted(() => {
for (const item of items.value) URL.revokeObjectURL(item.preview)
})
</script>
<template>
<div>
<section class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<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/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 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--neutral" @click="router.push('/profile')">返回个人主页</NButton>
</div>
</div>
</div>
<template v-else>
<div v-if="uploading" class="oc-panel-card mb-6 border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
</div>
<NProgress
type="line"
:show-indicator="false"
:percentage="overallProgress"
color="var(--oc-color-info)"
rail-color="var(--oc-color-border)"
:height="10"
/>
</div>
<div
v-if="items.length === 0"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop"
@click="fileInput?.click()"
class="oc-panel-card flex cursor-pointer flex-col items-center justify-center border-2 border-dashed bg-white py-20 transition-colors"
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'"
>
<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">支持 JPEG、PNG、WebP,单张不超过 20MB,可一次选择多张</p>
</div>
<template v-else>
<div class="flex min-w-0 flex-col gap-6 lg:flex-row">
<div class="w-full min-w-0 lg:w-[480px] lg:flex-shrink-0">
<div v-if="activeItem" class="mb-3 overflow-hidden border border-slate-900 bg-slate-900">
<img :src="activeItem.preview" alt="预览" class="h-64 w-full object-contain sm:h-[360px]" />
</div>
<div class="flex gap-2 overflow-x-auto pb-2">
<div
v-for="item in items"
:key="item.id"
@click="selectItem(item.id)"
class="relative flex-shrink-0 w-16 h-16 overflow-hidden cursor-pointer border-2 transition-colors bg-white"
:class="activeId === item.id ? 'border-sky-500' : 'border-transparent hover:border-slate-300'"
>
<img :src="item.preview" alt="" class="w-full h-full object-cover" />
<div v-if="Object.keys(item.errors).length > 0" class="absolute top-0 right-0 w-2.5 h-2.5 bg-red-500"></div>
<button
type="button"
@click.stop="handleRemove(item.id)"
class="oc-icon-button oc-icon-button--danger oc-icon-button--tiny absolute -right-1 -top-1"
title="移除图片"
aria-label="移除图片"
>
<NIcon size="12"><X /></NIcon>
</button>
</div>
<div
@click="fileInput?.click()"
class="flex h-16 w-16 flex-shrink-0 cursor-pointer items-center justify-center border-2 border-dashed border-slate-300 bg-white transition-colors hover:border-sky-400 hover:bg-slate-50"
>
<span class="text-xl text-slate-400">+</span>
</div>
</div>
</div>
<div class="flex-1 min-w-0" v-if="activeItem">
<NCard class="oc-panel-card border border-slate-200">
<div class="space-y-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-900">图片信息</h2>
<span class="text-sm text-slate-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
</div>
<!-- 类别 * -->
<div :id="`field-${activeItem.id}-cloudCategory`">
<label class="oc-field-label">
类别 <span class="oc-field-required">*</span>
</label>
<select
:value="activeItem.cloudCategoryId ?? ''"
@change="onCategoryChange"
class="oc-field-control"
>
<option value="" disabled>请选择</option>
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
{{ ct.name }}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
</option>
</select>
<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">拍摄时间</label>
<input
type="datetime-local"
:value="formatDatetimeLocal(activeItem.capturedAt)"
@change="onCapturedAtChange"
class="oc-field-control"
/>
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
</div>
<!-- 经纬度 * -->
<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
:value="activeItem.latitude !== null ? activeItem.latitude : ''"
@input="onLatInput"
type="number"
step="any"
placeholder="纬度(如 39.90)"
class="oc-field-control"
/>
<p v-if="activeItem.errors.latitude" class="oc-field-error">{{ activeItem.errors.latitude }}</p>
</div>
<div>
<input
:value="activeItem.longitude !== null ? activeItem.longitude : ''"
@input="onLngInput"
type="number"
step="any"
placeholder="经度(如 116.40)"
class="oc-field-control"
/>
<p v-if="activeItem.errors.longitude" class="oc-field-error">{{ activeItem.errors.longitude }}</p>
</div>
<div>
<button
type="button"
@click="openMapPicker"
title="地图选点"
aria-label="地图选点"
class="oc-icon-button oc-icon-button--sky"
>
<NIcon size="20"><MapIcon /></NIcon>
</button>
</div>
</div>
<p class="mt-1 text-xs text-slate-400">必填,可手动输入,或点击右侧地图图标选择</p>
</div>
<!-- 图片描述 -->
<div>
<label class="oc-field-label">图片描述</label>
<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>
<!-- 公开状态 -->
<div class="border border-slate-200 bg-slate-50 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-slate-800">公开展示</p>
<p class="mt-1 text-xs text-slate-500">关闭后不会出现在画廊、地图和公开主页中。</p>
</div>
<input
:checked="!activeItem.isHidden"
type="checkbox"
:id="'isPublic-' + activeItem.id"
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
@change="updateActivePublicState(($event.target as HTMLInputElement).checked)"
/>
</div>
</div>
</div>
</NCard>
</div>
</div>
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
<div class="flex flex-wrap gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">
{{ uploading ? '上传中...' : '提交上传' }}
</NButton>
</div>
</div>
<NAlert v-if="errorMsg" class="mt-3" type="error" :show-icon="false" :bordered="false" title="上传失败">
{{ errorMsg }}
</NAlert>
</template>
</template>
</div>
</div>
<MapPickerModal
:open="mapPickerOpen"
:latitude="activeItem?.latitude ?? null"
:longitude="activeItem?.longitude ?? null"
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
@close="closeMapPicker"
@confirm="confirmMapPicker"
/>
</template>