feat: adopt naive ui and refine shell interactions

This commit is contained in:
2026-05-21 20:53:24 +08:00
parent 6d8acce295
commit 78b1c952e7
13 changed files with 1046 additions and 572 deletions
+187 -209
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { NAlert, NButton, NCard, NProgress, NResult, NTag } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useCloudsStore } from '@/stores/clouds'
import { useAuthStore } from '@/stores/auth'
@@ -293,95 +294,84 @@ onUnmounted(() => {
</script>
<template>
<div class="max-w-6xl mx-auto px-4 py-8">
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
<div>
<section class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
<div class="max-w-6xl mx-auto px-4 py-10">
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Cloud Upload</p>
<h1 class="mt-3 text-4xl font-bold text-slate-900">上传云图</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
批量整理你的云图记录类别和拍摄时间必填经纬度既可以手动输入也可以在迷你地图里点选回填
</p>
</div>
</section>
<div class="max-w-6xl mx-auto px-4 py-8">
<input ref="fileInput" type="file" accept="image/*" 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">
<div class="text-center">
<span class="text-5xl block mb-4"></span>
<h2 class="text-3xl font-bold text-gray-900 mb-2">上传成功</h2>
<p class="text-gray-500">
<template v-if="unlockedBadges.length">
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章
</template>
<template v-else>
这批云图已经进入你的收藏记录
</template>
</p>
</div>
<NResult status="success" title="上传成功" class="border border-slate-200 bg-white shadow-sm">
<template #default>
<p class="text-slate-500">
<template v-if="unlockedBadges.length">
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章
</template>
<template v-else>
这批云图已经进入你的收藏记录
</template>
</p>
</template>
</NResult>
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<article
<NCard
v-for="badge in unlockedBadges"
:key="badge.cloudTypeId"
class="rounded-[28px] border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)] p-6 shadow-sm"
class="border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)] shadow-sm"
>
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-400 text-3xl text-white shadow-sm">
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white shadow-sm">
{{ badge.cloudName.slice(0, 1) }}
</div>
<div class="mt-5">
<div class="flex items-center gap-3">
<h3 class="text-2xl font-bold text-gray-900">{{ badge.cloudName }}</h3>
<span class="rounded-full border border-amber-200 bg-white px-3 py-1 text-xs font-medium text-amber-700">
<NTag :bordered="false" type="warning">
{{ rarityLabel(badge.rarity) }}
</span>
</NTag>
</div>
<p class="mt-1 text-sm text-gray-500">{{ badge.cloudNameEn }}</p>
<p class="mt-4 text-sm text-gray-600">解锁时间{{ formatUnlockedAt(badge.unlockedAt) }}</p>
</div>
<div class="mt-6 flex gap-3">
<button
@click="saveBadge(badge)"
class="flex-1 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-slate-800"
>
保存分享卡片
</button>
<button
@click="router.push(`/encyclopedia/${badge.cloudTypeId}`)"
class="flex-1 rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
查看详情
</button>
<NButton secondary strong type="primary" class="flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
<NButton secondary strong class="flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
</div>
</article>
</NCard>
</div>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
@click="resetAfterSuccess"
class="rounded-xl border border-gray-300 px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
继续上传
</button>
<button
@click="router.push('/encyclopedia')"
class="rounded-xl bg-sky-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-sky-600"
>
前往图鉴
</button>
<button
@click="router.push('/profile')"
class="rounded-xl border border-gray-300 px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
返回个人主页
</button>
<NButton secondary strong @click="resetAfterSuccess">继续上传</NButton>
<NButton secondary strong type="primary" @click="router.push('/encyclopedia')">前往图鉴</NButton>
<NButton secondary strong @click="router.push('/profile')">返回个人主页</NButton>
</div>
</div>
</div>
<template v-else>
<h1 class="text-2xl font-bold text-gray-900 mb-6">📷 上传云图</h1>
<div v-if="uploading" class="mb-6">
<div v-if="uploading" class="mb-6 border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div class="bg-sky-500 h-full rounded-full transition-all duration-300" :style="{ width: overallProgress + '%' }"></div>
</div>
<NProgress
type="line"
:show-indicator="false"
:percentage="overallProgress"
color="#0ea5e9"
rail-color="#dbe4ee"
:height="10"
/>
</div>
<div
@@ -390,7 +380,7 @@ onUnmounted(() => {
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop"
@click="fileInput?.click()"
class="flex flex-col items-center justify-center border-2 border-dashed rounded-2xl py-20 cursor-pointer transition-colors"
class="flex flex-col items-center justify-center border-2 border-dashed py-20 cursor-pointer transition-colors bg-white shadow-sm"
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-gray-300 hover:border-sky-400 hover:bg-gray-50'"
>
<span class="text-5xl mb-4"></span>
@@ -401,7 +391,7 @@ onUnmounted(() => {
<template v-else>
<div class="flex gap-6">
<div class="flex-shrink-0 w-[480px]">
<div v-if="activeItem" class="bg-gray-900 rounded-xl overflow-hidden mb-3">
<div v-if="activeItem" class="bg-gray-900 overflow-hidden mb-3 border border-slate-900">
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
</div>
<div class="flex gap-2 overflow-x-auto pb-2">
@@ -409,20 +399,20 @@ onUnmounted(() => {
v-for="item in items"
:key="item.id"
@click="selectItem(item.id)"
class="relative flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden cursor-pointer border-2 transition-colors"
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-gray-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 rounded-full"></div>
<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
@click.stop="handleRemove(item.id)"
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center"
class="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center bg-red-500 text-xs text-white"
style="opacity:0.8"
></button>
</div>
<div
@click="fileInput?.click()"
class="flex-shrink-0 w-16 h-16 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer hover:border-sky-400 hover:bg-gray-50 transition-colors"
class="flex-shrink-0 w-16 h-16 border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer bg-white hover:border-sky-400 hover:bg-gray-50 transition-colors"
>
<span class="text-gray-400 text-xl">+</span>
</div>
@@ -430,161 +420,154 @@ onUnmounted(() => {
</div>
<div class="flex-1 min-w-0" v-if="activeItem">
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">图片信息</h2>
<span class="text-sm text-gray-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
</div>
<NCard class="border border-gray-200 shadow-sm">
<div class="space-y-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">图片信息</h2>
<span class="text-sm text-gray-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
</div>
<!-- 类别 * -->
<div :id="`field-${activeItem.id}-cloudCategory`">
<label class="block text-sm font-medium text-gray-700 mb-1">
类别 <span class="text-red-500">*</span>
</label>
<select
:value="activeItem.cloudCategoryId ?? ''"
@change="onCategoryChange"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors bg-white"
>
<option value="" disabled>请选择</option>
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
{{ ct.name }}{{ ct.name_en }}
</option>
<option value="other">其他</option>
</select>
<div v-if="activeItem.cloudCategoryId === 'other'" class="mt-2">
<!-- 类别 * -->
<div :id="`field-${activeItem.id}-cloudCategory`">
<label class="block text-sm font-medium text-gray-700 mb-1">
类别 <span class="text-red-500">*</span>
</label>
<select
:value="activeItem.cloudCategoryId ?? ''"
@change="onCategoryChange"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors bg-white"
>
<option value="" disabled>请选择</option>
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
{{ ct.name }}{{ ct.name_en }}
</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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
</div>
<p v-if="activeItem.errors.cloudCategory" class="text-sm text-red-500 mt-1">{{ activeItem.errors.cloudCategory }}</p>
</div>
<!-- 拍摄时间 * -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
拍摄时间 <span class="text-red-500">*</span>
</label>
<input
v-model="activeItem.customCloudType"
type="datetime-local"
:value="formatDatetimeLocal(activeItem.capturedAt)"
@change="onCapturedAtChange"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
<p v-if="activeItem.errors.capturedAt" class="text-sm text-red-500 mt-1">{{ activeItem.errors.capturedAt }}</p>
</div>
<!-- 经纬度 -->
<div>
<div class="mb-1 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-gray-700">经纬度</label>
<button
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
type="button"
@click="clearCoordinates"
class="text-xs font-medium text-gray-400 transition-colors hover:text-gray-600"
>
清空
</button>
</div>
<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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
<p v-if="activeItem.errors.latitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.latitude }}</p>
</div>
<div>
<input
:value="activeItem.longitude !== null ? activeItem.longitude : ''"
@input="onLngInput"
type="number"
step="any"
placeholder="经度(如 116.40"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
<p v-if="activeItem.errors.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
</div>
<div>
<button
type="button"
@click="openMapPicker"
title="地图选点"
aria-label="地图选点"
class="flex h-10 w-10 items-center justify-center rounded-lg border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
>
<span class="text-lg leading-none">🗺</span>
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">选填可手动输入或点击右侧小地图图标选点</p>
</div>
<!-- 位置名称 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">位置名称</label>
<input
v-model="activeItem.locationName"
type="text"
placeholder="输入云型名称"
@input="activeItem.errors = {}"
placeholder="如:北京、成都"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
</div>
<p v-if="activeItem.errors.cloudCategory" class="text-sm text-red-500 mt-1">{{ activeItem.errors.cloudCategory }}</p>
</div>
<!-- 拍摄时间 * -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
拍摄时间 <span class="text-red-500">*</span>
</label>
<input
type="datetime-local"
:value="formatDatetimeLocal(activeItem.capturedAt)"
@change="onCapturedAtChange"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
<p v-if="activeItem.errors.capturedAt" class="text-sm text-red-500 mt-1">{{ activeItem.errors.capturedAt }}</p>
</div>
<!-- 经纬度 -->
<div>
<div class="mb-1 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-gray-700">经纬度</label>
<button
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
type="button"
@click="clearCoordinates"
class="text-xs font-medium text-gray-400 transition-colors hover:text-gray-600"
>
清空
</button>
<!-- 图片描述 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">图片描述</label>
<textarea
v-model="activeItem.description"
rows="3"
placeholder="描述一下这张图片..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors resize-none"
></textarea>
</div>
<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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
<p v-if="activeItem.errors.latitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.latitude }}</p>
</div>
<div>
<input
:value="activeItem.longitude !== null ? activeItem.longitude : ''"
@input="onLngInput"
type="number"
step="any"
placeholder="经度(如 116.40"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
<p v-if="activeItem.errors.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
</div>
<div>
<button
type="button"
@click="openMapPicker"
title="地图选点"
aria-label="地图选点"
class="flex h-10 w-10 items-center justify-center rounded-lg border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
>
<span class="text-lg leading-none">🗺</span>
</button>
</div>
<!-- 隐身模式 -->
<div class="flex items-center gap-2">
<input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" />
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式地图不显示位置</label>
</div>
<p class="text-xs text-gray-400 mt-1">选填可手动输入或点击右侧小地图图标选点</p>
</div>
<!-- 位置名称 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">位置名称</label>
<input
v-model="activeItem.locationName"
type="text"
placeholder="如:北京、成都"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
</div>
<!-- 图片描述 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">图片描述</label>
<textarea
v-model="activeItem.description"
rows="3"
placeholder="描述一下这张图片..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors resize-none"
></textarea>
</div>
<!-- 隐身模式 -->
<div class="flex items-center gap-2">
<input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" />
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式地图不显示位置</label>
</div>
</div>
</NCard>
</div>
</div>
<div class="mt-6 flex items-center justify-between">
<p class="text-sm text-gray-500"> {{ items.length }} 张图片类别和拍摄时间为必填项</p>
<div class="flex gap-3">
<button
@click="clearAll()"
:disabled="uploading"
class="px-5 py-2.5 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
清空
</button>
<button
@click="handleSubmit"
:disabled="uploading"
class="px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 disabled:opacity-50 transition-colors"
>
<NButton secondary strong @click="clearAll()" :disabled="uploading">清空</NButton>
<NButton type="primary" secondary strong @click="handleSubmit" :disabled="uploading">
{{ uploading ? '上传中...' : '提交上传' }}
</button>
</NButton>
</div>
</div>
<div v-if="errorMsg" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-600">{{ errorMsg }}</p>
</div>
<NAlert v-if="errorMsg" class="mt-3" type="error" :show-icon="false" :bordered="false" title="上传失败">
{{ errorMsg }}
</NAlert>
</template>
</template>
</div>
</div>
<Teleport to="body">
@@ -594,7 +577,7 @@ onUnmounted(() => {
@click="closeMapPicker"
>
<div
class="w-full max-w-3xl overflow-hidden rounded-[28px] bg-white shadow-2xl"
class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl"
@click.stop
>
<div class="flex items-start justify-between border-b border-gray-200 px-6 py-5">
@@ -612,13 +595,13 @@ onUnmounted(() => {
</div>
<div class="px-6 py-5">
<div class="mb-4 flex flex-wrap items-center gap-3 rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">
<div class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<span>当前选择</span>
<span class="font-medium text-slate-900">纬度 {{ formatCoordinate(mapPickerLat) }}</span>
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(mapPickerLng) }}</span>
</div>
<div class="relative overflow-hidden rounded-3xl border border-gray-200 bg-slate-100">
<div class="relative overflow-hidden border border-gray-200 bg-slate-100">
<div ref="miniMapEl" class="h-[420px] w-full"></div>
<div
@@ -640,21 +623,16 @@ onUnmounted(() => {
<div class="flex items-center justify-between border-t border-gray-200 px-6 py-4">
<p class="text-sm text-gray-500">建议点选大致拍摄位置上传时会继续做模糊化处理</p>
<div class="flex gap-3">
<button
type="button"
@click="closeMapPicker"
class="rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
取消
</button>
<button
type="button"
<NButton secondary strong @click="closeMapPicker">取消</NButton>
<NButton
type="primary"
secondary
strong
:disabled="mapPickerLat === null || mapPickerLng === null"
@click="confirmMapPicker"
class="rounded-xl bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
使用这个位置
</button>
</NButton>
</div>
</div>
</div>