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
+27 -24
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
import { createClient } from '@supabase/supabase-js'
const router = useRouter()
@@ -69,38 +70,40 @@ onUnmounted(() => {
</script>
<template>
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4">
<div class="w-full max-w-sm text-center">
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto max-w-3xl">
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<template v-if="state === 'success'">
<span class="text-5xl block mb-4"></span>
<h1 class="text-2xl font-bold text-gray-900 mb-2">邮箱认证成功</h1>
<p class="text-gray-500 mb-2">你的邮箱已确认现在可以登录了</p>
<p class="text-sm text-gray-400 mb-6">{{ countdown }} 秒后自动跳转登录页面...</p>
<RouterLink
to="/login"
class="inline-flex items-center px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 transition-colors"
>
立即登录
</RouterLink>
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
<template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
<RouterLink to="/login" class="no-underline">
<NButton type="primary">立即登录</NButton>
</RouterLink>
</div>
</template>
</NResult>
</template>
<template v-else-if="state === 'failed'">
<span class="text-5xl block mb-4"></span>
<h1 class="text-2xl font-bold text-gray-900 mb-2">认证失败</h1>
<p class="text-gray-500 mb-6">邮箱确认链接无效或已过期请重新注册</p>
<RouterLink
to="/register"
class="inline-flex items-center px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 transition-colors"
>
重新注册
</RouterLink>
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
<template #footer>
<RouterLink to="/register" class="no-underline">
<NButton type="primary">重新注册</NButton>
</RouterLink>
</template>
</NResult>
</template>
<template v-else>
<span class="text-5xl block mb-4 animate-pulse"></span>
<h1 class="text-2xl font-bold text-gray-900 mb-2">正在验证...</h1>
<p class="text-gray-500">请稍候</p>
<div class="flex flex-col items-center justify-center py-12 text-center">
<NSpin size="large" />
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证...</h1>
<p class="mt-2 text-slate-500">请稍候</p>
</div>
</template>
</NCard>
</div>
</div>
</template>
+68 -44
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -28,56 +29,79 @@ async function handleLogin() {
</script>
<template>
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<span class="text-5xl block mb-4"></span>
<h1 class="text-2xl font-bold text-gray-900">登录 OpenCloud</h1>
<p class="text-sm text-gray-500 mt-1">记录你眼中的每一朵云</p>
</div>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<section class="border border-slate-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#ffffff_48%,#eff6ff_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
登录后继续记录
<span class="block text-teal-700">你眼中的每一朵云</span>
</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
在地图图鉴和画廊之间同步你的观测记录上传新的云图后图鉴会自动点亮画廊也会按时间收纳你的作品
</p>
<div class="mt-8 flex gap-4">
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Collection</div>
<div class="mt-2 text-2xl font-bold text-slate-900">10</div>
<div class="mt-1 text-sm text-slate-500">基础云属待收集</div>
</div>
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Mode</div>
<div class="mt-2 text-2xl font-bold text-slate-900">Atlas</div>
<div class="mt-1 text-sm text-slate-500">地图图鉴画廊一体</div>
</div>
</div>
</section>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
<input
v-model="email"
type="email"
required
autocomplete="email"
placeholder="your@email.com"
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"
/>
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码继续你的天空档案</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<input
v-model="password"
type="password"
required
autocomplete="current-password"
placeholder="输入密码"
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"
/>
</div>
<NForm @submit.prevent="handleLogin">
<NFormItem label="邮箱">
<NInput
v-model:value="email"
required
autocomplete="email"
placeholder="your@email.com"
/>
</NFormItem>
<div v-if="error" class="p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-600">{{ error }}</p>
</div>
<NFormItem label="密码">
<NInput
v-model:value="password"
type="password"
required
show-password-on="click"
autocomplete="current-password"
placeholder="输入密码"
/>
</NFormItem>
<button
type="submit"
:disabled="loading"
class="w-full py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 disabled:opacity-50 transition-colors"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<NAlert v-if="error" type="error" class="mb-4">
{{ error }}
</NAlert>
<p class="mt-6 text-center text-sm text-gray-500">
没有账号
<RouterLink to="/register" class="text-sky-600 hover:text-sky-700 font-medium">去注册</RouterLink>
</p>
<NButton
attr-type="submit"
type="primary"
block
size="large"
:loading="loading"
>
{{ loading ? '登录中...' : '登录' }}
</NButton>
</NForm>
<p class="mt-6 text-sm text-slate-500">
没有账号
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
</p>
</NCard>
</div>
</div>
</template>
+95 -82
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -45,98 +46,110 @@ async function handleRegister() {
</script>
<template>
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4">
<div class="w-full max-w-sm">
<div v-if="emailSent" class="text-center">
<span class="text-5xl block mb-4">📧</span>
<h1 class="text-2xl font-bold text-gray-900 mb-2">确认你的邮箱</h1>
<p class="text-gray-500 mb-6">
我们已向 <strong class="text-gray-700">{{ email }}</strong> 发送了确认邮件请查收并点击确认链接完成注册
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<section class="border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
加入天空探索者
<span class="block text-sky-700">建立你的云图档案</span>
</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
注册后即可上传云图点亮图鉴在社区画廊里按时间展示你的观测记录所有页面都会围绕你的个人云层档案同步更新
</p>
<p class="text-sm text-gray-400 mb-6">没有收到请检查垃圾邮件文件夹</p>
<RouterLink
to="/login"
class="inline-flex items-center px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 transition-colors"
</section>
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NResult
v-if="emailSent"
status="success"
title="确认你的邮箱"
description="确认邮件已经发送,请查收并点击链接完成注册。"
>
去登录
</RouterLink>
</div>
<template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">
目标邮箱
<span class="font-semibold text-slate-700">{{ email }}</span>
</p>
<RouterLink to="/login" class="no-underline">
<NButton type="primary">去登录</NButton>
</RouterLink>
</div>
</template>
</NResult>
<template v-else>
<div class="text-center mb-8">
<span class="text-5xl block mb-4"></span>
<h1 class="text-2xl font-bold text-gray-900">注册 OpenCloud</h1>
<p class="text-sm text-gray-500 mt-1">加入天空探索者社区</p>
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
<input
v-model="username"
type="text"
required
autocomplete="username"
placeholder="你的昵称"
maxlength="20"
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"
/>
<template v-else>
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Create Account</div>
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
<p class="mt-2 text-sm text-slate-500">加入社区后即可开始记录收集与展示云图</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
<input
v-model="email"
type="email"
required
autocomplete="email"
placeholder="your@email.com"
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"
/>
</div>
<NForm @submit.prevent="handleRegister">
<NFormItem label="用户名">
<NInput
v-model:value="username"
type="text"
required
autocomplete="username"
placeholder="你的昵称"
maxlength="20"
/>
</NFormItem>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<input
v-model="password"
type="password"
required
autocomplete="new-password"
placeholder="至少8位"
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"
/>
</div>
<NFormItem label="邮箱">
<NInput
v-model:value="email"
required
autocomplete="email"
placeholder="your@email.com"
/>
</NFormItem>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
<input
v-model="confirmPassword"
type="password"
required
autocomplete="new-password"
placeholder="再次输入密码"
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"
/>
</div>
<NFormItem label="密码">
<NInput
v-model:value="password"
type="password"
required
show-password-on="click"
autocomplete="new-password"
placeholder="至少8位"
/>
</NFormItem>
<div v-if="error" class="p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-600">{{ error }}</p>
</div>
<NFormItem label="确认密码">
<NInput
v-model:value="confirmPassword"
type="password"
required
show-password-on="click"
autocomplete="new-password"
placeholder="再次输入密码"
/>
</NFormItem>
<button
type="submit"
:disabled="loading"
class="w-full py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 disabled:opacity-50 transition-colors"
>
{{ loading ? '注册中...' : '注册' }}
</button>
</form>
<NAlert v-if="error" type="error" class="mb-4">
{{ error }}
</NAlert>
<p class="mt-6 text-center text-sm text-gray-500">
已有账号
<RouterLink to="/login" class="text-sky-600 hover:text-sky-700 font-medium">去登录</RouterLink>
</p>
</template>
<NButton
attr-type="submit"
type="primary"
block
size="large"
:loading="loading"
>
{{ loading ? '注册中...' : '注册' }}
</NButton>
</NForm>
<p class="mt-6 text-sm text-slate-500">
已有账号
<RouterLink to="/login" class="font-semibold text-sky-700 hover:text-sky-800">去登录</RouterLink>
</p>
</template>
</NCard>
</div>
</div>
</template>
+34 -21
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
@@ -136,24 +137,33 @@ watch(() => route.params.id, loadPage)
<template>
<div class="max-w-6xl mx-auto px-4 py-8">
<div class="mb-6">
<RouterLink to="/encyclopedia" class="text-sm font-medium text-sky-700 hover:text-sky-800">
返回图鉴总览
<RouterLink to="/encyclopedia">
<NButton text type="primary"> 返回图鉴总览</NButton>
</RouterLink>
</div>
<div v-if="loading" class="space-y-6">
<div class="h-80 animate-pulse rounded-[32px] bg-slate-200"></div>
<NSkeleton class="h-80 w-full" />
<div class="grid gap-4 lg:grid-cols-3">
<div v-for="n in 3" :key="n" class="h-28 animate-pulse rounded-3xl bg-slate-200"></div>
<NCard v-for="n in 3" :key="n">
<NSkeleton class="h-4 w-1/3" />
<NSkeleton class="mt-4 h-7 w-1/2" />
</NCard>
</div>
</div>
<div v-else-if="loadError" class="rounded-[28px] border border-red-200 bg-red-50 p-6 text-red-700">
<NAlert
v-else-if="loadError"
type="error"
:show-icon="false"
:bordered="false"
title="详情加载失败"
>
{{ loadError }}
</div>
</NAlert>
<template v-else-if="cloudType">
<section class="overflow-hidden rounded-[32px] border border-slate-200 bg-white shadow-sm">
<section class="overflow-hidden border border-slate-200 bg-white shadow-sm">
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
<div class="relative min-h-[360px] overflow-hidden">
<img
@@ -169,9 +179,9 @@ watch(() => route.params.id, loadPage)
></div>
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/78 via-slate-950/18 to-transparent"></div>
<div class="absolute bottom-8 left-8 right-8 text-white">
<span class="inline-flex rounded-full border px-3 py-1 text-xs font-medium backdrop-blur" :class="rarityMeta[cloudType.rarity].chip">
<NTag :bordered="false" class="border border-white/20 bg-white/12 text-white backdrop-blur">
{{ rarityMeta[cloudType.rarity].label }}
</span>
</NTag>
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
<p class="mt-2 text-lg text-white/82">{{ cloudType.name_en }}</p>
</div>
@@ -185,7 +195,7 @@ watch(() => route.params.id, loadPage)
</p>
</div>
<div class="mt-8 rounded-[28px] border border-slate-200 bg-white p-5">
<NCard class="mt-8 border border-slate-200">
<p class="text-sm text-slate-500">你的收集状态</p>
<template v-if="isUnlocked && collectionEntry">
<p class="mt-2 text-2xl font-bold text-slate-900">已解锁</p>
@@ -195,26 +205,26 @@ watch(() => route.params.id, loadPage)
<p class="mt-2 text-2xl font-bold text-slate-900">尚未解锁</p>
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后这枚徽章就会被点亮</p>
</template>
</div>
</NCard>
</div>
</div>
</section>
<section class="mt-6 grid gap-4 md:grid-cols-3">
<div class="rounded-[28px] border border-slate-200 bg-white p-5 shadow-sm">
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">稀有度</p>
<p class="mt-2 text-2xl font-bold text-slate-900">{{ rarityMeta[cloudType.rarity].label }}</p>
</div>
<div class="rounded-[28px] border border-slate-200 bg-white p-5 shadow-sm">
</NCard>
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">公开云图</p>
<p class="mt-2 text-2xl font-bold text-slate-900">{{ publicCount }}</p>
</div>
<div class="rounded-[28px] border border-slate-200 bg-white p-5 shadow-sm">
</NCard>
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">首次解锁</p>
<p class="mt-2 text-xl font-bold text-slate-900">
{{ collectionEntry ? formatDate(collectionEntry.unlocked_at) : '等待记录' }}
</p>
</div>
</NCard>
</section>
<section class="mt-8">
@@ -229,7 +239,7 @@ watch(() => route.params.id, loadPage)
<article
v-for="item in gallery"
:key="item.id"
class="overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-sm"
class="overflow-hidden border border-slate-200 bg-white shadow-sm"
>
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
<div class="p-5">
@@ -245,9 +255,12 @@ watch(() => route.params.id, loadPage)
</article>
</div>
<div v-else class="rounded-[28px] border border-dashed border-slate-300 bg-white p-8 text-center">
<p class="text-lg font-semibold text-slate-900">还没有公开作品</p>
<p class="mt-2 text-sm text-slate-500">等第一位观测者上传这类云图后这里就会展示出来</p>
<div v-else class="border border-dashed border-slate-300 bg-white p-8">
<NEmpty description="还没有公开作品">
<template #extra>
<p class="text-sm text-slate-500">等第一位观测者上传这类云图后这里就会展示出来</p>
</template>
</NEmpty>
</div>
</section>
</template>
+37 -17
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NAlert, NCard, NEmpty, NProgress, NSkeleton } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
@@ -74,23 +75,26 @@ onMounted(async () => {
</p>
</div>
<div class="rounded-[28px] border border-white/80 bg-white/80 p-6 shadow-sm backdrop-blur">
<NCard class="border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500">当前进度</p>
<p class="mt-1 text-3xl font-bold text-slate-900">{{ progressText }}</p>
</div>
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-xl font-semibold text-white">
<div class="flex h-16 w-16 items-center justify-center border border-slate-900 bg-slate-900 text-xl font-semibold text-white">
{{ authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0 }}%
</div>
</div>
<div class="mt-5 h-3 overflow-hidden rounded-full bg-slate-200">
<div
class="h-full rounded-full bg-[linear-gradient(90deg,#0ea5e9_0%,#f59e0b_100%)] transition-all duration-500"
:style="{ width: `${authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0}%` }"
></div>
</div>
<NProgress
class="mt-5"
type="line"
:show-indicator="false"
:percentage="authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0"
color="linear-gradient(90deg,#0ea5e9 0%,#f59e0b 100%)"
rail-color="#dbe4ee"
:height="12"
/>
<p class="mt-4 text-sm text-slate-500">
<template v-if="authStore.isLoggedIn">
@@ -100,27 +104,39 @@ onMounted(async () => {
登录后可同步你的个人图鉴进度
</template>
</p>
</div>
</NCard>
</div>
</div>
</div>
<div class="max-w-6xl mx-auto px-4 py-8">
<div v-if="encyclopediaStore.collectionError && authStore.isLoggedIn" class="mb-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
<NAlert
v-if="encyclopediaStore.collectionError && authStore.isLoggedIn"
class="mb-6"
type="warning"
:show-icon="false"
:bordered="false"
title="图鉴收藏数据暂时不可用"
>
图鉴收藏数据暂时不可用{{ encyclopediaStore.collectionError }}
</div>
</NAlert>
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<div v-for="n in 6" :key="n" class="h-72 animate-pulse rounded-[28px] bg-slate-200"></div>
<NCard v-for="n in 6" :key="n">
<NSkeleton class="h-44 w-full" />
<NSkeleton class="mt-5 h-5 w-2/5" />
<NSkeleton class="mt-3 h-4 w-1/3" />
<NSkeleton class="mt-6 h-4 w-full" :repeat="2" />
</NCard>
</div>
<div v-else class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<button
v-for="cloudType in encyclopediaStore.cloudTypes"
:key="cloudType.id"
type="button"
@click="openCard(cloudType)"
class="group overflow-hidden rounded-[28px] border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
class="group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
:class="isUnlocked(cloudType.id) ? 'border-amber-300 shadow-amber-100/60' : 'border-slate-200 hover:border-slate-300'"
>
<div
@@ -145,11 +161,11 @@ onMounted(async () => {
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent"></div>
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/22 backdrop-blur-[2px]">
<div class="rounded-full border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">🔒 尚未解锁</div>
<div class="border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">🔒 尚未解锁</div>
</div>
<div class="absolute left-4 top-4">
<span class="inline-flex rounded-full border px-3 py-1 text-xs font-medium" :class="rarityMeta[cloudType.rarity].chip">
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[cloudType.rarity].chip">
{{ rarityMeta[cloudType.rarity].label }}
</span>
</div>
@@ -178,11 +194,15 @@ onMounted(async () => {
</div>
</button>
</div>
<div v-else class="border border-dashed border-slate-300 bg-white px-6 py-12">
<NEmpty description="暂时还没有图鉴数据" />
</div>
</div>
<div
v-if="lockHint"
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-lg"
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 border border-slate-900 bg-slate-900 px-4 py-2 text-sm text-white shadow-lg"
>
{{ lockHint }}
</div>
+108 -103
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { NAlert, NButton, NEmpty, NSkeleton, NTag } from 'naive-ui'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import { supabase } from '@/lib/supabase'
import { useCloudsStore } from '@/stores/clouds'
@@ -197,129 +198,133 @@ onUnmounted(() => {
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Community Gallery</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">
按上传时间倒序浏览社区云图卡片采用 Instagram 风格的整齐宫格排布悬停即可快速查看基本信息点开可看大图和详细记录
按上传时间倒序浏览社区云图瀑布流会尽量保留图片原始比例悬停即可快速查看基本信息点开可看大图和详细记录
</p>
</div>
</section>
<div class="max-w-7xl mx-auto px-4 py-8">
<section>
<div class="flex gap-3 overflow-x-auto pb-2">
<button
<section>
<div class="flex gap-3 overflow-x-auto pb-2">
<NButton
v-for="tab in filterTabs"
:key="tab.id"
type="button"
secondary
strong
@click="selectedTypeId = tab.id"
class="shrink-0 rounded-full border px-4 py-2 text-sm font-medium transition-colors"
:class="selectedTypeId === tab.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900'"
class="shrink-0"
:type="selectedTypeId === tab.id ? 'primary' : 'default'"
>
{{ tab.label }}
</button>
</div>
</section>
</NButton>
</div>
</section>
<div v-if="loadError" class="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ loadError }}
</div>
<NAlert v-if="loadError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="画廊加载失败">
{{ loadError }}
</NAlert>
<section v-if="loading" class="mt-6 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
<div v-for="n in 8" :key="n" class="aspect-square animate-pulse rounded-[26px] bg-slate-200"></div>
</section>
<section v-if="loading" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
<div
v-for="n in 8"
:key="n"
class="mb-3 break-inside-avoid border border-slate-200 bg-white p-3"
>
<NSkeleton class="h-44 w-full" />
<NSkeleton class="mt-4 h-4 w-3/5" />
<NSkeleton class="mt-3 h-3 w-2/5" />
<NSkeleton class="mt-2 h-3 w-4/5" />
</div>
</section>
<section v-else-if="galleryItems.length" class="mt-6 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
<button
v-for="cloud in galleryItems"
:key="cloud.id"
type="button"
@click="openDetail(cloud)"
class="group relative aspect-square overflow-hidden rounded-[26px] bg-slate-200 text-left shadow-sm"
>
<img
:src="cloud.thumbnail_url || cloud.image_url"
:alt="cloud.cloudTypeName"
class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]"
/>
<section v-else-if="galleryItems.length" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
<button
v-for="cloud in galleryItems"
:key="cloud.id"
type="button"
@click="openDetail(cloud)"
class="group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<img
:src="cloud.thumbnail_url || cloud.image_url"
:alt="cloud.cloudTypeName"
class="block h-auto w-full object-cover transition duration-500 group-hover:scale-[1.04]"
/>
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/82 via-slate-950/8 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<div class="rounded-2xl bg-black/28 px-3 py-3 backdrop-blur-[2px]">
<div class="flex items-center justify-between gap-3">
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
<span class="shrink-0 rounded-full border border-white/20 bg-white/10 px-2 py-0.5 text-[11px]">
{{ rarityMeta[cloud.cloudTypeRarity].label }}
</span>
<div class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/88 via-slate-950/45 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<div>
<div class="flex items-center justify-between gap-3">
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
<NTag size="small" :bordered="false" class="shrink-0 bg-white/12 text-white backdrop-blur">
{{ rarityMeta[cloud.cloudTypeRarity].label }}
</NTag>
</div>
<p class="mt-2 truncate text-xs text-white/82">📷 {{ cloud.username }}</p>
<p class="mt-1 truncate text-xs text-white/82">🕐 {{ formatUploadTime(cloud) }}</p>
<p class="mt-1 truncate text-xs text-white/68">{{ cloud.location_name || '未填写位置' }}</p>
</div>
<p class="mt-2 truncate text-xs text-white/78">📷 {{ cloud.username }}</p>
<p class="mt-1 truncate text-xs text-white/78">🕐 {{ formatUploadTime(cloud) }}</p>
<p class="mt-1 truncate text-xs text-white/65">{{ cloud.location_name || '未填写位置' }}</p>
</div>
</button>
</section>
<section v-else class="mt-6 border border-dashed border-slate-300 bg-white px-6 py-12">
<NEmpty description="还没有符合条件的云图">
<template #extra>
<p class="text-sm text-slate-500">换个云型筛选试试或者等社区上传更多作品</p>
</template>
</NEmpty>
</section>
<div ref="sentinel" class="h-10"></div>
<div v-if="loadingMore" class="flex justify-center py-4">
<NButton secondary disabled>正在加载更多云图...</NButton>
</div>
<div v-else-if="hasMore && galleryItems.length" class="flex justify-center py-4">
<NButton secondary strong @click="loadMore">手动加载更多</NButton>
</div>
<ImageDetailModal
v-if="selectedCloud"
:open="!!selectedCloud"
:image-url="selectedCloud.image_url"
:image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName"
:subtitle="`上传者:${selectedCloud.username}`"
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
@close="closeDetail"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatUploadTime(selectedCloud) }}</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatCapturedTime(selectedCloud) }}</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">模糊化经纬度</p>
<p class="mt-2 text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
</p>
</div>
</div>
</button>
</section>
<section v-else class="mt-6 rounded-[28px] border border-dashed border-slate-300 bg-white px-6 py-12 text-center">
<p class="text-xl font-semibold text-slate-900">还没有符合条件的云</p>
<p class="mt-2 text-sm text-slate-500">换个云型筛选试试或者等社区上传更多作品</p>
</section>
<div ref="sentinel" class="h-10"></div>
<div v-if="loadingMore" class="flex justify-center py-4">
<div class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm text-slate-500 shadow-sm">
正在加载更多云图...
</div>
</div>
<div v-else-if="hasMore && galleryItems.length" class="flex justify-center py-4">
<button
type="button"
@click="loadMore"
class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:text-slate-900"
>
手动加载更多
</button>
</div>
<ImageDetailModal
v-if="selectedCloud"
:open="!!selectedCloud"
:image-url="selectedCloud.image_url"
:image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName"
:subtitle="`上传者:${selectedCloud.username}`"
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
@close="closeDetail"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatUploadTime(selectedCloud) }}</p>
</div>
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatCapturedTime(selectedCloud) }}</p>
</div>
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
</div>
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">模糊化经纬度</p>
<p class="mt-2 text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
<div class="mt-5 border border-slate-200 bg-white p-5">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p>
</div>
</div>
<div class="mt-5 rounded-[28px] border border-slate-200 bg-white p-5">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p>
</div>
</ImageDetailModal>
</ImageDetailModal>
</div>
</div>
</template>
+9 -1
View File
@@ -26,6 +26,7 @@ const statusText = ref('加载中...')
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
const MIN_MARKER_OPACITY = 0.3
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
let AMapLib: typeof AMap | null = null
let mapInst: AMap.Map | null = null
@@ -135,6 +136,10 @@ function hideHoverCard() {
hoverIW = null
}
function hideHeader() {
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
}
async function loadClouds(): Promise<CloudMarkerData[]> {
const { data, error } = await supabase
.from('clouds')
@@ -261,6 +266,9 @@ onMounted(async () => {
mapInst.addControl(new AMapLib.ToolBar({ position: 'LT' } as Record<string, unknown>))
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '10px', top: '80px' } } as Record<string, unknown>))
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
mapInst.on('zoomstart', hideHeader)
mapInst.on('movestart', hideHeader)
mapInst.on('dragstart', hideHeader)
await refresh()
startMarkerDecayTimer()
} catch (e) {
@@ -280,7 +288,7 @@ onUnmounted(() => {
</script>
<template>
<div class="relative h-[calc(100vh-4rem)]">
<div class="relative h-[100dvh] min-h-screen">
<div ref="mapEl" class="w-full h-full"></div>
<div class="absolute bottom-6 right-4 flex flex-col gap-2 z-10">
+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>