## 概述 本分支对前端进行了大规模适配与升级,核心是对齐新版后端 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
404 lines
16 KiB
Vue
404 lines
16 KiB
Vue
<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>
|