feat(api)!: 适配新版后端接口

切换到 Cookie 会话和新版 DTO,并迁移画廊、地图、个人主页、百科及上传流程。停用后端尚未实现的账号与徽章功能,同时纳入新版 API 契约文档。

BREAKING CHANGE: 前端不再兼容旧 /api/v1 接口和 refresh token 流程。
This commit is contained in:
2026-08-16 00:34:04 +08:00
parent cd70f89a28
commit c78b2f8ca5
31 changed files with 1750 additions and 2184 deletions
+2 -85
View File
@@ -1,90 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
import { apiRequest } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const state = ref<'loading' | 'success' | 'failed'>('loading')
const countdown = ref(5)
let countdownTimer: ReturnType<typeof setInterval> | null = null
const confirmationToken = typeof route.query.token === 'string' ? route.query.token : ''
if (route.query.token !== undefined) {
window.history.replaceState(window.history.state, '', window.location.pathname)
}
function startCountdown() {
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
if (countdownTimer) clearInterval(countdownTimer)
router.push('/login')
}
}, 1000)
}
onMounted(async () => {
try {
if (!confirmationToken) {
state.value = 'failed'
return
}
await apiRequest('/auth/confirm-email', {
method: 'POST',
auth: false,
body: { token: confirmationToken },
})
state.value = 'success'
startCountdown()
} catch {
state.value = 'failed'
}
})
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
import UnavailableAuthView from '@/views/auth/UnavailableAuthView.vue'
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto max-w-3xl">
<NCard class="oc-panel-card-xl">
<template v-if="state === 'success'">
<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" class="oc-primary-button oc-primary-button--teal">立即登录</NButton>
</RouterLink>
</div>
</template>
</NResult>
</template>
<template v-else-if="state === 'failed'">
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
<template #footer>
<RouterLink to="/register" class="no-underline">
<NButton type="primary" class="oc-primary-button oc-primary-button--sky">重新注册</NButton>
</RouterLink>
</template>
</NResult>
</template>
<template v-else>
<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>
<UnavailableAuthView />
</template>
+2 -174
View File
@@ -1,179 +1,7 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const route = useRoute()
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
const error = ref('')
const loading = ref(false)
const submitted = ref(false)
const cooldown = ref(0)
let cooldownTimer: ReturnType<typeof setInterval> | null = null
const resendButtonLabel = computed(() => {
if (loading.value) return '发送中...'
if (cooldown.value > 0) return `重新发送(${cooldown.value}s)`
return '重新发送'
})
function clearCooldownTimer() {
if (!cooldownTimer) return
clearInterval(cooldownTimer)
cooldownTimer = null
}
function startCooldown() {
clearCooldownTimer()
cooldown.value = 60
cooldownTimer = setInterval(() => {
cooldown.value--
if (cooldown.value <= 0) {
cooldown.value = 0
clearCooldownTimer()
}
}, 1000)
}
async function handleSendResetEmail() {
error.value = ''
if (!email.value.trim()) {
error.value = '请输入需要找回密码的邮箱。'
return
}
loading.value = true
try {
await authStore.sendPasswordReset(email.value.trim())
submitted.value = true
startCooldown()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试。'
} finally {
loading.value = false
}
}
async function resendResetEmail() {
if (cooldown.value > 0 || loading.value) {
return
}
await handleSendResetEmail()
}
onUnmounted(() => {
clearCooldownTimer()
})
import UnavailableAuthView from '@/views/auth/UnavailableAuthView.vue'
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<section class="oc-panel-card-xl min-w-0 overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f2faf6_100%)] p-6 sm:p-8">
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
重新拿回你的
<span class="block text-sky-700">OpenCloud 账号</span>
</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
输入注册邮箱后,我们会发送一封密码重置邮件。打开邮件中的链接,即可进入新的密码设置页面。
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Step 1</div>
<div class="mt-2 text-lg font-bold text-slate-900">发送邮件</div>
<div class="oc-field-help">使用注册邮箱请求重置链接</div>
</div>
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Step 2</div>
<div class="mt-2 text-lg font-bold text-slate-900">设置新密码</div>
<div class="oc-field-help">在专用页面完成新密码更新</div>
</div>
</div>
</section>
<NCard class="oc-panel-card-xl min-w-0">
<NResult
v-if="submitted"
status="success"
title="重置邮件已发送"
description="如果该邮箱已注册,请查收邮件中的重置链接。"
>
<template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">
目标邮箱:
<span class="font-semibold text-slate-700">{{ email }}</span>
</p>
<div class="flex flex-wrap justify-center gap-3">
<NButton
type="primary"
class="oc-primary-button oc-primary-button--teal"
:disabled="cooldown > 0"
:loading="loading"
@click="resendResetEmail"
>
{{ resendButtonLabel }}
</NButton>
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
</RouterLink>
</div>
</div>
</template>
</NResult>
<template v-else>
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Reset Request</div>
<h2 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h2>
<p class="mt-2 text-sm text-slate-500">输入你的注册邮箱,我们会发送一封密码重置邮件。</p>
</div>
<NForm @submit.prevent="handleSendResetEmail">
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'email',
inputmode: 'email',
name: 'email',
required: true,
}"
placeholder="your@email.com"
/>
</NFormItem>
<NAlert v-if="error" type="error" class="mb-4">
{{ error }}
</NAlert>
<NButton
attr-type="submit"
type="primary"
block
size="large"
class="oc-primary-button oc-primary-button--sky"
:loading="loading"
>
{{ loading ? '发送中...' : '发送重置邮件' }}
</NButton>
</NForm>
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
<p>如果你已经记起密码,可以直接返回登录。</p>
<RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">
返回登录
</RouterLink>
</div>
</template>
</NCard>
</div>
</div>
<UnavailableAuthView />
</template>
+5 -14
View File
@@ -51,19 +51,19 @@ async function handleLogin() {
<span class="block text-teal-700">你眼中的每一朵云</span>
</h1>
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
在地图、百科和画廊之间浏览观测记录。上传新的云图后,审核通过的作品会进入公开画廊与地图。
</p> -->
</div>
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Collection</div>
<div class="oc-stat-value">10</div>
<div class="oc-field-help">基础云属待收集</div>
<div class="oc-field-help">基础云属资料</div>
</div>
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Mode</div>
<div class="oc-stat-value">Atlas</div>
<div class="oc-field-help">地图、图鉴、画廊一体</div>
<div class="oc-field-help">地图、百科、画廊一体</div>
</div>
</div>
</section>
@@ -120,17 +120,8 @@ async function handleLogin() {
</NButton>
</NForm>
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
<p>
没有账号?
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
</p>
<RouterLink
:to="email ? { path: '/forgot-password', query: { email } } : { path: '/forgot-password' }"
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
>
忘记密码?
</RouterLink>
<div class="mt-6 text-sm text-slate-500">
注册与密码找回暂未开放;如需账号协助,请联系管理员。
</div>
</NCard>
</div>
+2 -179
View File
@@ -1,184 +1,7 @@
<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()
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const username = ref('')
const error = ref('')
const loading = ref(false)
const emailSent = ref(false)
async function handleRegister() {
error.value = ''
if (!username.value.trim()) {
error.value = '请输入用户名'
return
}
if (username.value.length > 20) {
error.value = '用户名不能超过20个字符'
return
}
if (password.value.length < 8) {
error.value = '密码至少8位'
return
}
if (password.value !== confirmPassword.value) {
error.value = '两次密码不一致'
return
}
loading.value = true
try {
await authStore.register(email.value, password.value, username.value.trim())
emailSent.value = true
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
} finally {
loading.value = false
}
}
import UnavailableAuthView from '@/views/auth/UnavailableAuthView.vue'
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 sm:p-8">
<div>
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
加入天空探索者
<span class="block text-sky-700">建立你的云图档案</span>
</h1>
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
</p> -->
</div>
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Review</div>
<div class="oc-stat-value">Queue</div>
<div class="oc-field-help">上传后进入审核队列</div>
</div>
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Profile</div>
<div class="oc-stat-value">Journal</div>
<div class="oc-field-help">自动生成个人天空日志</div>
</div>
</div>
</section>
<NCard class="oc-panel-card-xl min-w-0 h-full">
<NResult
v-if="emailSent"
status="success"
title="确认你的邮箱"
description="确认邮件已经发送,请查收并点击链接完成注册。"
>
<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" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
</RouterLink>
</div>
</template>
</NResult>
<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>
<NForm @submit.prevent="handleRegister">
<NFormItem label="用户名">
<NInput
v-model:value="username"
type="text"
:input-props="{
autocomplete: 'username',
name: 'username',
required: true,
}"
placeholder="你的昵称"
maxlength="20"
/>
</NFormItem>
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'email',
inputmode: 'email',
name: 'email',
required: true,
}"
placeholder="your@email.com"
/>
</NFormItem>
<NFormItem label="密码">
<NInput
v-model:value="password"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
placeholder="至少8位"
/>
</NFormItem>
<NFormItem label="确认密码">
<NInput
v-model:value="confirmPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
placeholder="再次输入密码"
/>
</NFormItem>
<NAlert v-if="error" type="error" class="mb-4">
{{ error }}
</NAlert>
<NButton
attr-type="submit"
type="primary"
block
size="large"
class="oc-primary-button oc-primary-button--sky"
: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>
<UnavailableAuthView />
</template>
+2 -204
View File
@@ -1,209 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
import { apiRequest } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const password = ref('')
const confirmPassword = ref('')
const error = ref('')
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
const loading = ref(false)
const countdown = ref(5)
const initialResetToken = typeof route.query.token === 'string' ? route.query.token : ''
const resetToken = ref(initialResetToken)
let countdownTimer: ReturnType<typeof setInterval> | null = null
if (route.query.token !== undefined) {
window.history.replaceState(window.history.state, '', window.location.pathname)
}
const canSubmit = computed(() =>
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
)
function showInvalidRecoveryLink() {
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
state.value = 'invalid'
}
function getResetErrorMessage(value: unknown) {
const message = value instanceof Error ? value.message : ''
if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
return '密码重置链接无效或已过期,请重新发送邮件。'
}
if (message.includes('8')) {
return '新密码至少需要 8 位。'
}
return message || '密码重置失败,请稍后重试。'
}
function startCountdown() {
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
if (countdownTimer) clearInterval(countdownTimer)
router.push('/login')
}
}, 1000)
}
async function initializeRecoverySession() {
if (!resetToken.value) {
showInvalidRecoveryLink()
return
}
state.value = 'ready'
}
async function handleResetPassword() {
error.value = ''
if (password.value.length < 8) {
error.value = '新密码至少需要 8 位。'
return
}
if (password.value !== confirmPassword.value) {
error.value = '两次输入的密码不一致。'
return
}
loading.value = true
try {
await apiRequest('/auth/reset-password', {
method: 'POST',
auth: false,
body: { token: resetToken.value, password: password.value },
})
countdown.value = 5
state.value = 'success'
startCountdown()
} catch (e) {
error.value = getResetErrorMessage(e)
if (error.value === '密码重置链接无效或已过期,请重新发送邮件。') {
state.value = 'invalid'
}
} finally {
loading.value = false
}
}
onMounted(() => {
void initializeRecoverySession()
})
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
import UnavailableAuthView from '@/views/auth/UnavailableAuthView.vue'
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto max-w-2xl">
<NCard class="oc-panel-card-xl">
<NResult
v-if="state === 'success'"
status="success"
title="密码已重置"
description="你已使用新密码完成更新,现在可以重新登录。"
>
<template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
</div>
</template>
</NResult>
<template v-else-if="state === 'checking'">
<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>
<template v-else>
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
<p class="mt-2 text-sm text-slate-500">
{{
state === 'invalid'
? '当前重置链接不可用,请重新发送密码重置邮件。'
: '请输入新的登录密码,提交后当前重置链接会失效。'
}}
</p>
</div>
<NForm @submit.prevent="handleResetPassword">
<input
type="text"
name="username"
autocomplete="username"
class="sr-only"
tabindex="-1"
aria-label="账户用户名"
/>
<NFormItem label="新密码">
<NInput
v-model:value="password"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
placeholder="至少 8 位"
/>
</NFormItem>
<NFormItem label="确认新密码">
<NInput
v-model:value="confirmPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
placeholder="再次输入新密码"
/>
</NFormItem>
<NAlert v-if="error" type="error" class="mb-4">
{{ error }}
</NAlert>
<RouterLink
v-if="state === 'invalid'"
to="/forgot-password"
class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800"
>
返回重新发送重置邮件
</RouterLink>
<NButton
attr-type="submit"
type="primary"
block
size="large"
class="oc-primary-button oc-primary-button--teal"
:disabled="!canSubmit"
:loading="loading"
>
保存新密码
</NButton>
</NForm>
</template>
</NCard>
</div>
</div>
<UnavailableAuthView />
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { NButton, NCard, NResult } from 'naive-ui'
const route = useRoute()
const copy = computed(() => {
if (route.name === 'register') {
return {
title: '注册暂未开放',
description: '新后端的账号激活流程尚未完成。为避免创建无法登录的账号,前端暂时关闭注册。',
}
}
if (route.name === 'auth-confirm') {
return {
title: '邮箱确认暂不可用',
description: '新后端尚未实现邮箱确认接口,请等待服务恢复后再使用确认链接。',
}
}
return {
title: '密码找回暂不可用',
description: '新后端尚未实现忘记密码与重置密码接口。如需恢复账号,请联系管理员。',
}
})
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto max-w-2xl">
<NCard class="oc-panel-card-xl">
<NResult status="info" :title="copy.title" :description="copy.description">
<template #footer>
<RouterLink to="/login" class="no-underline">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--teal">
返回登录
</NButton>
</RouterLink>
</template>
</NResult>
</NCard>
</div>
</div>
</template>
+69 -249
View File
@@ -1,126 +1,60 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
import { NAlert, NButton, NEmpty, NSkeleton } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { apiRequest, toApiCloud } from '@/lib/api'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database'
interface CloudGalleryItem {
id: string
image_url: string
thumbnail_url: string | null
latitude: number | null
longitude: number | null
location_name: string | null
description: string | null
captured_at: string | null
created_at: string
profiles: {
username: string
} | null
}
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
const PAGE_SIZE = 24
const route = useRoute()
const authStore = useAuthStore()
const encyclopediaStore = useEncyclopediaStore()
const loading = ref(true)
const loadError = ref('')
const gallery = ref<CloudGalleryItem[]>([])
const selectedGalleryItem = ref<CloudGalleryItem | null>(null)
const publicCount = ref(0)
const rarityMeta = {
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200', glow: 'from-sky-100 via-white to-cyan-50' },
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200', glow: 'from-amber-100 via-white to-orange-50' },
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 via-white to-orange-50' },
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
const gallery = ref<ApiCloud[]>([])
const selectedItem = ref<ApiCloud | null>(null)
const currentPage = ref(1)
const hasNextPage = ref(false)
const cloudTypeId = computed(() => Number(route.params.id))
const cloudType = computed(() => encyclopediaStore.cloudTypes.find(item => item.id === cloudTypeId.value) ?? null)
const collectionEntry = computed(() => {
if (!authStore.isLoggedIn || !cloudType.value) return null
return encyclopediaStore.getCollectionEntry(cloudType.value.id)
})
const isUnlocked = computed(() => !!collectionEntry.value)
const heroImage = computed(() => collectionEntry.value?.firstCloud?.image_url || gallery.value[0]?.image_url || null)
function formatDate(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function formatGalleryTime(item: CloudGalleryItem) {
return formatDate(item.captured_at || item.created_at)
}
const heroImage = computed(() => gallery.value[0]?.image_url || null)
function formatDateTime(iso: string | null) {
if (!iso) return '未知时间'
if (!iso) return '未记录拍摄时间'
return new Date(iso).toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
function formatCoordinate(value: number | null) {
return value === null ? '未记录' : value.toFixed(2)
async function loadGallery(page: number) {
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud/type/${cloudTypeId.value}?${params}`, { auth: false })
gallery.value = rows.map(toApiCloud)
currentPage.value = page
hasNextPage.value = rows.length === PAGE_SIZE
}
function openGalleryDetail(item: CloudGalleryItem) {
selectedGalleryItem.value = item
}
async function loadGallery(typeId: number) {
const result = await apiRequest<ApiPage<ApiCloud>>(`/cloud-types/${typeId}/clouds?page=1&page_size=24`, { auth: false })
gallery.value = result.items.map(row => ({
id: row.id,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
latitude: row.latitude,
longitude: row.longitude,
location_name: row.location_name,
description: row.description,
captured_at: row.captured_at,
created_at: row.created_at,
profiles: row.username ? { username: row.username } : null,
}))
publicCount.value = result.total
}
async function loadPage() {
if (!Number.isFinite(cloudTypeId.value) || cloudTypeId.value <= 0) {
loadError.value = '云型不存在'
async function loadPage(page = 1) {
if (!Number.isInteger(cloudTypeId.value) || cloudTypeId.value <= 0) {
loadError.value = '云类型不存在'
loading.value = false
return
}
loading.value = true
loadError.value = ''
selectedItem.value = null
try {
await encyclopediaStore.fetchCloudTypes()
await encyclopediaStore.fetchMyCollection()
if (!cloudType.value) {
loadError.value = '云型不存在'
loadError.value = '云类型不存在'
gallery.value = []
publicCount.value = 0
return
}
await loadGallery(cloudType.value.id)
await loadGallery(page)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '详情加载失败'
} finally {
@@ -128,191 +62,77 @@ async function loadPage() {
}
}
onMounted(loadPage)
watch(() => route.params.id, loadPage)
watch(() => route.params.id, () => {
selectedGalleryItem.value = null
})
onMounted(() => loadPage())
watch(() => route.params.id, () => loadPage())
</script>
<template>
<div class="max-w-6xl mx-auto px-4 py-8">
<div class="mb-6">
<RouterLink to="/encyclopedia">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回图鉴总览</NButton>
</RouterLink>
</div>
<div class="oc-container oc-container--content py-8">
<RouterLink to="/encyclopedia">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回百科总览</NButton>
</RouterLink>
<div v-if="loading" class="space-y-6">
<div v-if="loading" class="mt-6 space-y-6">
<NSkeleton class="h-80 w-full" />
<div class="grid gap-4 lg:grid-cols-3">
<NCard v-for="n in 3" :key="n" class="oc-panel-card border border-slate-200">
<NSkeleton class="h-4 w-1/3" />
<NSkeleton class="mt-4 h-7 w-1/2" />
</NCard>
</div>
<div class="grid gap-4 md:grid-cols-3"><NSkeleton v-for="n in 6" :key="n" class="h-56 w-full" /></div>
</div>
<NAlert
v-else-if="loadError"
type="error"
:show-icon="false"
:bordered="false"
title="详情加载失败"
>
<NAlert v-else-if="loadError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="详情加载失败">
{{ loadError }}
</NAlert>
<template v-else-if="cloudType">
<section class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
<div class="relative min-h-[360px] overflow-hidden">
<img
v-if="heroImage"
:src="heroImage"
:alt="cloudType.name"
class="absolute inset-0 h-full w-full object-cover"
/>
<div
v-else
class="absolute inset-0 bg-gradient-to-br"
:class="rarityMeta[cloudType.rarity].glow"
></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">
<NTag :bordered="false" class="border border-white/20 bg-white/12 text-white backdrop-blur">
{{ rarityMeta[cloudType.rarity].label }}
</NTag>
<section class="oc-panel-card mt-6 overflow-hidden border border-slate-200 bg-white">
<div class="grid lg:grid-cols-[1.05fr_0.95fr]">
<div class="relative min-h-80 bg-sky-50">
<img v-if="heroImage" :src="heroImage" :alt="cloudType.name" class="absolute inset-0 h-full w-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/10 to-transparent"></div>
<div class="absolute bottom-7 left-7 right-7 text-white">
<span class="border border-white/35 bg-slate-950/25 px-2.5 py-1 text-xs backdrop-blur">稀有度 {{ cloudType.rarity_value }}</span>
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
<p class="mt-2 text-lg text-white/82">{{ cloudType.name_en }}</p>
<p class="mt-2 text-white/80">{{ cloudType.name_en || '未提供云属名称' }}</p>
</div>
</div>
<div class="flex flex-col justify-between bg-slate-50 p-8">
<div>
<p class="text-sm font-medium uppercase tracking-[0.22em] text-slate-500">Meteorology Note</p>
<p class="mt-5 text-base leading-8 text-slate-700">
{{ cloudType.description || '这类云的图像、形成条件和观测要点会随着图鉴完善逐步补充。' }}
</p>
</div>
<div class="oc-inset-panel mt-8">
<p class="text-sm text-slate-500">你的收集状态</p>
<template v-if="isUnlocked && collectionEntry">
<p class="oc-stat-value">已解锁</p>
<p class="mt-2 text-sm text-slate-600">首次解锁于 {{ formatDate(collectionEntry.unlocked_at) }}</p>
</template>
<template v-else>
<p class="oc-stat-value">尚未解锁</p>
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后,这枚徽章就会被点亮。</p>
</template>
</div>
<div class="p-8">
<p class="oc-section-label">Meteorology Note</p>
<p class="mt-5 text-base leading-8 text-slate-700">{{ cloudType.description || '暂未提供识别说明。' }}</p>
</div>
</div>
</section>
<section class="mt-6 grid gap-4 md:grid-cols-3">
<NCard class="oc-panel-card border border-slate-200">
<p class="text-sm text-slate-500">稀有度</p>
<p class="oc-stat-value">{{ rarityMeta[cloudType.rarity].label }}</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200">
<p class="text-sm text-slate-500">公开云图</p>
<p class="oc-stat-value">{{ publicCount }}</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200">
<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>
</NCard>
</section>
<section class="mt-8">
<div class="mb-4 flex items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-900">该类型云图画廊</h2>
<p class="oc-field-help">来自社区公开可见的已通过云图。</p>
<div class="mb-5 flex items-end justify-between gap-4">
<div><h2 class="text-2xl font-bold text-slate-900">公开观测</h2><p class="oc-field-help">第 {{ currentPage }} 页,每页最多 {{ PAGE_SIZE }} 张。</p></div>
<div class="flex gap-3">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" :disabled="currentPage === 1" @click="loadPage(currentPage - 1)">上一页</NButton>
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :disabled="!hasNextPage" @click="loadPage(currentPage + 1)">下一页</NButton>
</div>
</div>
<div v-if="gallery.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<button
v-for="item in gallery"
:key="item.id"
type="button"
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left transition-transform duration-300 hover:-translate-y-1"
@click="openGalleryDetail(item)"
>
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
<div class="p-5">
<div class="flex items-center justify-between gap-4">
<p class="text-sm font-medium text-slate-900">{{ item.profiles?.username || '匿名' }}</p>
<p class="text-xs text-slate-400">{{ formatGalleryTime(item) }}</p>
</div>
<p class="mt-2 text-sm text-slate-600">{{ item.location_name || '未填写位置' }}</p>
<p v-if="item.description" class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
{{ item.description }}
</p>
</div>
<div v-if="gallery.length" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<button v-for="item in gallery" :key="item.id" type="button" class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left" @click="selectedItem = item">
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-48 w-full object-cover" />
<div class="p-4"><p class="font-medium text-slate-900">@{{ item.username }}</p><p class="mt-1 text-xs text-slate-500">{{ formatDateTime(item.captured_at || item.created_at) }}</p></div>
</button>
</div>
<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>
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10"><NEmpty description="这一页暂无公开云图" /></div>
</section>
<ImageDetailModal
v-if="selectedGalleryItem"
:open="!!selectedGalleryItem"
:image-url="selectedGalleryItem.image_url"
:thumbnail-url="selectedGalleryItem.thumbnail_url"
:image-alt="cloudType.name"
:title="cloudType.name"
:subtitle="`上传者:${selectedGalleryItem.profiles?.username || '匿名'}`"
:badge-label="rarityMeta[cloudType.rarity].label"
:badge-class="rarityMeta[cloudType.rarity].chip"
@close="selectedGalleryItem = null"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="oc-inset-panel">
<p class="oc-section-label">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedGalleryItem.created_at) }}</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedGalleryItem.captured_at) }}</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedGalleryItem.location_name || '未填写位置名称' }}</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">模糊化经纬度</p>
<p class="mt-2 text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(selectedGalleryItem.latitude) }} / 经度 {{ formatCoordinate(selectedGalleryItem.longitude) }}
</p>
</div>
</div>
<div class="mt-5 border border-slate-200 bg-white p-5">
<p class="oc-section-label">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedGalleryItem.description || '上传者没有留下额外说明。' }}
</p>
</div>
<MiniLocationMap
:latitude="selectedGalleryItem.latitude"
:longitude="selectedGalleryItem.longitude"
:location-name="selectedGalleryItem.location_name"
/>
</ImageDetailModal>
</template>
<ImageDetailModal
v-if="selectedItem && cloudType"
:open="true"
:image-url="selectedItem.image_url"
:thumbnail-url="selectedItem.thumbnail_url"
:image-alt="cloudType.name"
:title="cloudType.name"
:subtitle="selectedItem.captured_at ? `拍摄于 ${formatDateTime(selectedItem.captured_at)}` : `上传于 ${formatDateTime(selectedItem.created_at)}`"
:badge-label="`稀有度 ${cloudType.rarity_value}`"
badge-class="border-slate-200 bg-slate-50 text-slate-600"
@close="selectedItem = null"
>
<p class="text-sm leading-7 text-slate-700">{{ selectedItem.description || '上传者没有留下额外说明。' }}</p>
<MiniLocationMap :latitude="selectedItem.latitude" :longitude="selectedItem.longitude" />
</ImageDetailModal>
</div>
</template>
+29 -188
View File
@@ -1,109 +1,33 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { NAlert, NEmpty, NIcon, NSkeleton } from 'naive-ui'
import { onMounted } from 'vue'
import { NEmpty, NSkeleton } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
import { Lock } from '@vicons/tabler'
import type { CloudType } from '@/types/database'
const router = useRouter()
const authStore = useAuthStore()
const encyclopediaStore = useEncyclopediaStore()
const lockHint = ref('')
let lockHintTimer: number | null = null
const rarityMeta = {
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200', glow: 'from-sky-100 to-white' },
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200', glow: 'from-amber-100 to-white' },
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 to-white' },
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
function isUnlocked(cloudTypeId: number) {
return authStore.isLoggedIn && encyclopediaStore.isUnlocked(cloudTypeId)
}
function getCollectionEntry(cloudTypeId: number) {
if (!authStore.isLoggedIn) return null
return encyclopediaStore.getCollectionEntry(cloudTypeId)
}
function formatUnlockedAt(iso: string) {
return new Date(iso).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
})
}
function showLockHint(name: string) {
lockHint.value = `拍到 ${name} 后,这枚徽章就会亮起来。`
if (lockHintTimer) window.clearTimeout(lockHintTimer)
lockHintTimer = window.setTimeout(() => {
lockHint.value = ''
lockHintTimer = null
}, 2200)
}
function openCard(cloudType: CloudType) {
if (isUnlocked(cloudType.id)) {
router.push(`/encyclopedia/${cloudType.id}`)
return
}
showLockHint(cloudType.name)
}
onMounted(async () => {
await encyclopediaStore.fetchCloudTypes()
await encyclopediaStore.fetchMyCollection()
})
onMounted(() => encyclopediaStore.fetchCloudTypes())
</script>
<template>
<div class="relative">
<div class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<div class="oc-page-hero__split">
<div class="min-w-0">
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
<h1 class="oc-page-title">云朵图鉴</h1>
<p class="oc-page-description">
收集 10 种基础云属。每拍到一种新的云型,图鉴里就会点亮一枚徽章,并记录你第一次遇见它的时间。
</p>
</div>
<EncyclopediaProgressCard
:unlocked-count="authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0"
:total-count="encyclopediaStore.cloudTypes.length || 10"
:is-logged-in="authStore.isLoggedIn"
/>
</div>
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
<h1 class="oc-page-title">云朵百科</h1>
<p class="oc-page-description">
浏览后端提供的云类型资料,了解云属名称、识别说明和社区公开观测记录。
</p>
</div>
</div>
<div class="oc-container oc-container--content oc-page-content">
<NAlert
v-if="encyclopediaStore.collectionError && authStore.isLoggedIn"
class="mb-6"
type="warning"
:show-icon="false"
:bordered="false"
title="图鉴收藏数据暂时不可用"
>
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
</NAlert>
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<div v-for="n in 6" :key="n" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<NSkeleton class="h-44 w-full border-b border-slate-200" />
<div class="p-5">
<NSkeleton class="h-3 w-1/3" />
<NSkeleton class="mt-3 h-6 w-2/5" />
<NSkeleton class="mt-2 h-3 w-1/2" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
<NSkeleton class="mt-5 h-10 w-full" />
</div>
<div v-for="n in 6" :key="n" class="oc-panel-card border border-slate-200 bg-white p-5">
<NSkeleton class="h-6 w-2/5" />
<NSkeleton class="mt-3 h-4 w-1/3" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="3" />
</div>
</div>
@@ -112,114 +36,31 @@ onMounted(async () => {
v-for="cloudType in encyclopediaStore.cloudTypes"
:key="cloudType.id"
type="button"
@click="openCard(cloudType)"
class="encyclopedia-type-card oc-interactive-card oc-panel-card flex h-full flex-col overflow-hidden border bg-white text-left"
:class="isUnlocked(cloudType.id) ? 'border-sky-200' : 'border-slate-200'"
class="oc-interactive-card oc-panel-card flex h-full flex-col border border-slate-200 bg-white p-5 text-left"
@click="router.push(`/encyclopedia/${cloudType.id}`)"
>
<div class="relative h-44 shrink-0 overflow-hidden border-b border-slate-200 bg-sky-50">
<img
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
:src="getCollectionEntry(cloudType.id)?.firstCloud?.thumbnail_url || getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
:alt="cloudType.name"
class="encyclopedia-type-card__image h-full w-full object-cover"
:class="isUnlocked(cloudType.id) ? '' : 'grayscale contrast-75'"
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br"
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-200 to-slate-50'"
>
<span class="font-mono text-5xl font-bold text-slate-500/75">{{ cloudType.name.slice(0, 1) }}</span>
</div>
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/12">
<span class="encyclopedia-lock-badge inline-flex items-center gap-2 border border-slate-300 bg-white/92 px-3 py-2 text-xs font-semibold text-slate-700 backdrop-blur">
<NIcon size="15"><Lock /></NIcon>
尚未收录
</span>
</div>
<div class="absolute left-4 top-4">
<span class="inline-flex border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em]" :class="rarityMeta[cloudType.rarity].chip">
{{ rarityMeta[cloudType.rarity].label }}
</span>
</div>
</div>
<div class="flex flex-1 flex-col p-5">
<p class="oc-section-label">Cloud Genus</p>
<h2 class="mt-2 text-xl font-bold text-slate-900">{{ cloudType.name }}</h2>
<p class="mt-1 font-mono text-xs uppercase tracking-[0.12em] text-slate-500">{{ cloudType.name_en }}</p>
<p class="mt-4 line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
{{ cloudType.description || '云层特征和识别要点会显示在这里。' }}
</p>
<div class="mt-5 flex items-center justify-between gap-3 border-t border-slate-200 pt-4 text-xs">
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
<span class="font-semibold uppercase tracking-[0.12em] text-sky-800">已收录</span>
<span class="text-right text-slate-500">首次记录 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
</template>
<template v-else>
<span class="font-semibold uppercase tracking-[0.12em] text-slate-600">等待发现</span>
<span class="text-right text-slate-400">拍到后点亮</span>
</template>
<div class="flex items-start justify-between gap-4">
<div>
<p class="oc-section-label">Cloud Genus</p>
<h2 class="mt-2 text-xl font-bold text-slate-900">{{ cloudType.name }}</h2>
<p class="mt-1 font-mono text-xs uppercase tracking-[0.12em] text-slate-500">
{{ cloudType.name_en || '未提供云属名称' }}
</p>
</div>
<span class="shrink-0 border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs font-medium text-slate-600">
稀有度 {{ cloudType.rarity_value }}
</span>
</div>
<p class="mt-5 line-clamp-3 text-sm leading-6 text-slate-600">
{{ cloudType.description || '暂未提供识别说明。' }}
</p>
<p class="mt-auto border-t border-slate-200 pt-4 text-xs font-semibold text-sky-700">查看类型资料与公开云图 →</p>
</button>
</div>
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12">
<NEmpty description="暂时还没有图鉴数据" />
<NEmpty description="暂时还没有云类型资料" />
</div>
</div>
<div
v-if="lockHint"
class="encyclopedia-lock-hint 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"
>
{{ lockHint }}
</div>
</div>
</template>
<style scoped>
.encyclopedia-lock-badge {
box-shadow: var(--oc-shadow-xs);
}
.encyclopedia-lock-hint {
box-shadow: var(--oc-shadow-lg);
}
.encyclopedia-type-card:hover {
border-color: rgb(125 211 252);
box-shadow: var(--oc-shadow-lg) !important;
transform: translate(-1px, -1px);
}
.encyclopedia-type-card:active {
box-shadow: var(--oc-shadow-md) !important;
transform: translate(0, 0);
}
.encyclopedia-type-card__image {
transition: transform 500ms ease;
}
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
transform: scale(1.035);
}
@media (prefers-reduced-motion: reduce) {
.encyclopedia-type-card,
.encyclopedia-type-card__image {
transition-duration: 1ms;
}
.encyclopedia-type-card:hover,
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
transform: none;
}
}
</style>
+59 -70
View File
@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { apiRequest } from '@/lib/api'
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useProfileStore } from '@/stores/profile'
import type { ApiCloud, ApiPage } from '@/types/api'
import type { CloudType } from '@/types/database'
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
interface GalleryCloud {
id: string
@@ -29,7 +29,7 @@ interface GalleryCloud {
status: 'pending' | 'approved' | 'rejected'
is_hidden: boolean
cloudTypeName: string
cloudTypeRarity: CloudType['rarity']
cloudTypeRarity: number
username: string
}
@@ -47,8 +47,7 @@ const selectedTypeId = ref<number | 'all'>('all')
const searchQuery = ref('')
const selectedCloud = ref<GalleryCloud | null>(null)
const currentPage = ref(1)
const totalCount = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / PAGE_SIZE)))
const hasNextPage = ref(false)
const manageError = ref('')
const editModalOpen = ref(false)
const editSaving = ref(false)
@@ -56,11 +55,7 @@ const editInitialValue = ref<CloudEditFormValue | null>(null)
let searchTimer: number | null = null
const rarityMeta = {
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
function formatDateTime(iso: string | null) {
if (!iso) return '未知时间'
@@ -105,7 +100,7 @@ function toGalleryCloud(row: ApiCloud) {
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
username: row.username || '匿名',
} satisfies GalleryCloud
}
@@ -116,14 +111,28 @@ async function loadPage(page: number) {
try {
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
if (normalizedSearch.value) params.set('search', normalizedSearch.value)
if (normalizedSearch.value) {
params.set('filter', isUserSearch.value ? 'owner' : 'type')
params.set('value', normalizedSearch.value)
} else if (selectedTypeId.value !== 'all') {
const typeName = cloudsStore.cloudTypes.find(type => type.id === selectedTypeId.value)?.name
if (typeName) {
params.set('filter', 'type')
params.set('value', typeName)
}
}
const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
totalCount.value = result.total
galleryItems.value = result.items.map(toGalleryCloud)
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud)
hasNextPage.value = rows.length === PAGE_SIZE
currentPage.value = page
} catch (error) {
if (error instanceof ApiError && error.status === 404 && isUserSearch.value) {
galleryItems.value = []
hasNextPage.value = false
currentPage.value = page
return
}
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
} finally {
loading.value = false
@@ -131,7 +140,7 @@ async function loadPage(page: number) {
}
async function goToPage(page: number) {
if (page < 1 || page > totalPages.value || page === currentPage.value) return
if (page < 1 || page === currentPage.value || (page > currentPage.value && !hasNextPage.value)) return
await loadPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
@@ -172,12 +181,10 @@ function openEditModal() {
manageError.value = ''
editInitialValue.value = {
cloudCategoryId: selectedCloud.value.cloud_type_id ?? (selectedCloud.value.custom_cloud_type ? 'other' : null),
customCloudType: selectedCloud.value.custom_cloud_type || '',
cloudCategoryId: selectedCloud.value.cloud_type_id,
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
latitude: selectedCloud.value.latitude,
longitude: selectedCloud.value.longitude,
locationName: selectedCloud.value.location_name || '',
description: selectedCloud.value.description || '',
isPublic: !selectedCloud.value.is_hidden,
}
@@ -204,18 +211,14 @@ async function submitEditModal(value: CloudEditFormValue) {
manageError.value = '请选择云型类别。'
return
}
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
manageError.value = '请输入自定义云型名称。'
return
}
if (!value.capturedAt) {
manageError.value = '请选择拍摄时间。'
return
}
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
if (hasLat !== hasLng) {
manageError.value = '经纬度必须同时填写。'
if (!hasLat || !hasLng) {
manageError.value = '经纬度为必填项。'
return
}
if (value.description.trim().length > 128) {
manageError.value = '图片描述不能超过 128 个字符。'
return
}
@@ -225,13 +228,11 @@ async function submitEditModal(value: CloudEditFormValue) {
try {
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
location_name: value.locationName.trim() || null,
description: value.description.trim() || null,
captured_at: new Date(value.capturedAt).toISOString(),
type_id: value.cloudCategoryId,
latitude: blurCoordinate(value.latitude as number),
longitude: blurCoordinate(value.longitude as number),
description: value.description.trim(),
...(value.capturedAt ? { captured_at: new Date(value.capturedAt).toISOString() } : {}),
is_hidden: !value.isPublic,
})
@@ -362,7 +363,7 @@ onUnmounted(() => {
v-model="searchQuery"
type="text"
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
placeholder="搜索云型名称,或输入 @用户名 查看某个用户的图片"
placeholder="输入完整云类型名称,或 @用户名进行精确搜索"
/>
<button
v-if="searchQuery"
@@ -382,7 +383,7 @@ onUnmounted(() => {
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
</template>
<template v-else>
正在匹配云型名称和自定义云型:{{ normalizedSearch }}
正在精确匹配云型名称:{{ normalizedSearch }}
</template>
</p>
</div>
@@ -444,7 +445,7 @@ onUnmounted(() => {
<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 }}
稀有度 {{ cloud.cloudTypeRarity }}
</NTag>
</div>
<p class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82">
@@ -463,7 +464,7 @@ onUnmounted(() => {
<NIcon size="14">
<Location />
</NIcon>
<span class="truncate">{{ cloud.location_name || '未填写位置' }}</span>
<span class="truncate">{{ formatCoordinate(cloud.latitude) }}, {{ formatCoordinate(cloud.longitude) }}</span>
</p>
</div>
</div>
@@ -478,7 +479,7 @@ onUnmounted(() => {
</NEmpty>
</section>
<div v-if="totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
<div v-if="currentPage > 1 || hasNextPage" class="mt-8 flex items-center justify-center gap-2">
<NButton
secondary
strong
@@ -490,28 +491,13 @@ onUnmounted(() => {
上一页
</NButton>
<template v-for="page in totalPages" :key="page">
<NButton
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2"
secondary
strong
:class="page === currentPage ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
type="default"
@click="goToPage(page)"
>
{{ page }}
</NButton>
<span
v-else-if="page === 2 || page === totalPages - 1"
class="px-1 text-sm text-slate-400"
>...</span>
</template>
<span class="border border-slate-200 bg-white px-4 py-2 text-sm text-slate-600">第 {{ currentPage }} 页</span>
<NButton
secondary
strong
type="default"
:disabled="currentPage >= totalPages"
:disabled="!hasNextPage"
class="oc-panel-button oc-panel-button--neutral"
@click="goToPage(currentPage + 1)"
>
@@ -519,10 +505,6 @@ onUnmounted(() => {
</NButton>
</div>
<div v-if="totalCount > 0" class="mt-2 text-center text-xs text-slate-400">
共 {{ totalCount }} 张照片,第 {{ currentPage }} / {{ totalPages }} 页
</div>
<ImageDetailModal
v-if="selectedCloud"
:open="!!selectedCloud"
@@ -531,8 +513,8 @@ onUnmounted(() => {
:image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName"
:subtitle="`上传者:${selectedCloud.username}`"
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
:badge-class="rarityChip"
@close="closeDetail"
>
<div class="grid gap-4 sm:grid-cols-2">
@@ -546,7 +528,7 @@ onUnmounted(() => {
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">模糊化经纬度</p>
@@ -556,13 +538,20 @@ onUnmounted(() => {
</div>
</div>
<div class="mt-5 border border-slate-200 bg-white p-5">
<div class="mt-5 border border-slate-200 bg-white p-5">
<p class="oc-section-label">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p>
</div>
<MiniLocationMap
</div>
<RouterLink
:to="{ name: 'user-profile', params: { id: selectedCloud.username } }"
class="oc-panel-button oc-panel-button--neutral mt-5 inline-flex"
>
查看 @{{ selectedCloud.username }} 的公开主页
</RouterLink>
<MiniLocationMap
:latitude="selectedCloud.latitude"
:longitude="selectedCloud.longitude"
:location-name="selectedCloud.location_name"
+17 -24
View File
@@ -3,10 +3,10 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
import { apiRequest } from '@/lib/api'
import { apiRequest, toApiCloud } from '@/lib/api'
import { loadAMap } from '@/lib/amap'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud } from '@/types/api'
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
import { NIcon } from 'naive-ui'
import { Adjustments, Calendar, CloudUpload, Minus, Plus, Refresh, Map, Satellite, X } from '@vicons/tabler'
@@ -19,7 +19,7 @@ interface CloudMarkerData {
locationName: string | null
description: string | null
cloudTypeName: string
rarity: 'common' | 'uncommon' | 'rare'
rarity: number
username: string
capturedAt: string
createdAt: string
@@ -66,17 +66,8 @@ let zoomAnimationValue = currentZoom.value
let zoomLastFrameTime = 0
let zoomHoldStarted = false
const rarityColors: Record<string, string> = {
common: '#cbd5e1',
uncommon: '#f59e0b',
rare: '#f43f5e',
}
const rarityMeta = {
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
} as const
const markerBorder = '#64748b'
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
const selectedTimeLabel = computed(() => formatMinuteOfDay(selectedMinuteOfDay.value))
const currentMinutePercent = computed(() => Math.min(100, Math.max(0, currentMinuteOfDay.value / 1439 * 100)))
@@ -178,7 +169,7 @@ function getMarkerOpacity(cloud: CloudMarkerData) {
}
function bubbleContent(cloud: CloudMarkerData): HTMLElement {
const border = rarityColors[cloud.rarity] || rarityColors.common
const border = markerBorder
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
@@ -199,9 +190,9 @@ function bubbleContent(cloud: CloudMarkerData): HTMLElement {
}
function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
const border = rarityColors[cloud.rarity] || rarityColors.common
const border = markerBorder
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
const label = cloud.rarity === 'common' ? '常见' : cloud.rarity === 'uncommon' ? '少见' : '罕见'
const label = `稀有度 ${cloud.rarity}`
const card = document.createElement('div')
card.style.cssText = 'background:#fff;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,0.18);overflow:hidden;width:200px;font-family:system-ui,-apple-system,sans-serif'
@@ -424,22 +415,22 @@ function toCloudMarker(row: ApiCloud): CloudMarkerData {
locationName: row.location_name,
description: row.description,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
rarity: row.cloud_type_rarity || 'common',
rarity: row.cloud_type_rarity_value ?? 0,
username: row.username || '匿名',
capturedAt: row.captured_at || row.created_at,
createdAt: row.created_at,
}
}
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
async function fetchCloudsByRange(field: 'captured_at' | 'uploaded_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
const params = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString(),
time_field: field,
})
try {
const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
return rows.map(toCloudMarker)
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud/map?${params}`, { auth: false })
return rows.map(toApiCloud).map(toCloudMarker)
} catch (error) {
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
return []
@@ -836,8 +827,8 @@ onUnmounted(() => {
:image-alt="previewCloud.cloudTypeName"
:title="previewCloud.cloudTypeName"
:subtitle="`上传者:${previewCloud.username}`"
:badge-label="rarityMeta[previewCloud.rarity].label"
:badge-class="rarityMeta[previewCloud.rarity].chip"
:badge-label="`稀有度 ${previewCloud.rarity}`"
:badge-class="rarityChip"
@close="previewCloud = null"
>
<div class="grid gap-4 sm:grid-cols-2">
@@ -851,7 +842,9 @@ onUnmounted(() => {
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ previewCloud.locationName || '未填写位置名称' }}</p>
<p class="mt-2 text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">模糊化经纬度</p>
+26 -8
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const message = useMessage()
const usernameDraft = ref('')
const currentPassword = ref('')
const newPassword = ref('')
const confirmNewPassword = ref('')
const error = ref('')
@@ -29,8 +31,8 @@ async function saveUsername() {
error.value = '昵称不能为空。'
return
}
if (nextUsername.length < 2) {
error.value = '昵称至少需要 2 个字符。'
if (nextUsername.length > 16) {
error.value = '昵称不能超过 16 个字符。'
return
}
if (nextUsername === authStore.profile?.username) {
@@ -62,14 +64,19 @@ async function savePassword() {
error.value = '两次输入的新密码不一致。'
return
}
if (!currentPassword.value) {
error.value = '请输入当前密码。'
return
}
passwordSaving.value = true
try {
await authStore.updatePassword(newPassword.value)
await authStore.updatePassword(currentPassword.value, newPassword.value)
currentPassword.value = ''
newPassword.value = ''
confirmNewPassword.value = ''
success.value = '密码已更新。'
message.success('密码已更新')
message.success('密码已更新,请重新登录')
await router.push('/login')
} catch (e) {
error.value = e instanceof Error ? e.message : '密码更新失败'
} finally {
@@ -89,7 +96,7 @@ async function savePassword() {
<p class="oc-page-eyebrow">Account Settings</p>
<h1 class="oc-page-title">个人资料设置</h1>
<p class="oc-page-description">
修改公开昵称和登录密码。昵称会显示在画廊、图鉴和地图中,且不能与其他用户重复。
修改公开昵称和登录密码。昵称会显示在画廊、百科和地图中,且不能与其他用户重复。
</p>
</div>
@@ -123,7 +130,7 @@ async function savePassword() {
<NInput
v-model:value="usernameDraft"
placeholder="输入新的昵称"
maxlength="32"
maxlength="16"
show-count
/>
<NButton
@@ -159,6 +166,17 @@ async function savePassword() {
aria-label="账户邮箱"
readonly
/>
<NInput
v-model:value="currentPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'current-password',
name: 'current-password',
required: true,
}"
placeholder="当前密码"
/>
<NInput
v-model:value="newPassword"
type="password"
+46 -65
View File
@@ -10,7 +10,7 @@ import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
import type { CloudType, Profile } from '@/types/database'
import type { Profile } from '@/types/database'
interface TimelineGroup {
key: string
@@ -34,11 +34,7 @@ const editSaving = ref(false)
const manageError = ref('')
const editInitialValue = ref<CloudEditFormValue | null>(null)
const rarityMeta = {
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
const statusMeta = {
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
@@ -46,35 +42,26 @@ const statusMeta = {
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
const viewedUserId = computed(() => {
const routeUserId = typeof route.params.id === 'string' ? route.params.id : null
return routeUserId || authStore.user?.id || null
const viewedIdentifier = computed(() => {
const routeUsername = typeof route.params.id === 'string' ? route.params.id : null
return routeUsername || authStore.user?.id || null
})
const isOwnProfile = computed(() => {
return !!viewedUserId.value && viewedUserId.value === authStore.user?.id
})
const isOwnProfile = computed(() => typeof route.params.id !== 'string')
const loading = computed(() => profileStore.isLoading(viewedUserId.value, isOwnProfile.value))
const loading = computed(() => profileStore.isLoading(viewedIdentifier.value, isOwnProfile.value))
const errorMsg = computed(() => {
if (!viewedUserId.value) return '未找到要查看的用户。'
return profileStore.getError(viewedUserId.value, isOwnProfile.value)
if (!viewedIdentifier.value) return '未找到要查看的用户。'
return profileStore.getError(viewedIdentifier.value, isOwnProfile.value)
})
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
const receivedLikes = computed(() => profileStore.getLikeStats(viewedUserId.value)?.total_likes || 0)
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedIdentifier.value))
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedIdentifier.value, isOwnProfile.value))
const receivedLikes = computed(() => isOwnProfile.value ? authStore.user?.received_like_count || 0 : 0)
const pageTitle = computed(() => {
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
})
// const profileSubtitle = computed(() => {
// if (isOwnProfile.value) {
// return '回看你的云图记录、收集进度和拍摄节奏。'
// }
// return '公开展示的云图记录会按时间顺序陈列在这里。'
// })
const totalShots = computed(() => clouds.value.length)
const approvedShots = computed(() => {
@@ -224,7 +211,9 @@ function formatCoordinate(value: number | null) {
}
function formatCloudTime(item: ProfileCloudItem) {
return formatDateTime(item.captured_at || item.created_at)
return item.captured_at
? `拍摄于 ${formatDateTime(item.captured_at)}`
: `上传于 ${formatDateTime(item.created_at)}`
}
function getProfileInitial(profile: Profile | null) {
@@ -274,12 +263,10 @@ function formatDatetimeLocal(iso: string | null) {
function openEditModal(cloud: ProfileCloudItem) {
manageError.value = ''
editInitialValue.value = {
cloudCategoryId: cloud.cloud_type_id ?? (cloud.custom_cloud_type ? 'other' : null),
customCloudType: cloud.custom_cloud_type || '',
cloudCategoryId: cloud.cloud_type_id,
capturedAt: formatDatetimeLocal(cloud.captured_at),
latitude: cloud.latitude,
longitude: cloud.longitude,
locationName: cloud.location_name || '',
description: cloud.description || '',
isPublic: !cloud.is_hidden,
}
@@ -297,24 +284,20 @@ function blurCoordinate(value: number): number {
}
async function submitEditModal(value: CloudEditFormValue) {
if (!selectedCloud.value || !viewedUserId.value) return
if (!selectedCloud.value || !viewedIdentifier.value) return
if (!value.cloudCategoryId) {
manageError.value = '请选择云型类别。'
return
}
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
manageError.value = '请输入自定义云型名称。'
return
}
if (!value.capturedAt) {
manageError.value = '请选择拍摄时间。'
return
}
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
if (hasLat !== hasLng) {
manageError.value = '经纬度必须同时填写。'
if (!hasLat || !hasLng) {
manageError.value = '经纬度为必填项。'
return
}
if (value.description.trim().length > 128) {
manageError.value = '图片描述不能超过 128 个字符。'
return
}
@@ -322,14 +305,12 @@ async function submitEditModal(value: CloudEditFormValue) {
manageError.value = ''
try {
const updated = await profileStore.updateCloud(viewedUserId.value, selectedCloud.value.id, {
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
location_name: value.locationName.trim() || null,
description: value.description.trim() || null,
captured_at: new Date(value.capturedAt).toISOString(),
const updated = await profileStore.updateCloud(viewedIdentifier.value, selectedCloud.value.id, {
type_id: value.cloudCategoryId,
latitude: blurCoordinate(value.latitude as number),
longitude: blurCoordinate(value.longitude as number),
description: value.description.trim(),
...(value.capturedAt ? { captured_at: new Date(value.capturedAt).toISOString() } : {}),
is_hidden: !value.isPublic,
})
selectedCloud.value = updated
@@ -342,13 +323,13 @@ async function submitEditModal(value: CloudEditFormValue) {
}
async function toggleSelectedCloudPrivacy() {
if (!selectedCloud.value || !viewedUserId.value) return
if (!selectedCloud.value || !viewedIdentifier.value) return
manageError.value = ''
const nextHidden = !selectedCloud.value.is_hidden
try {
await profileStore.updateCloudVisibility(viewedUserId.value, selectedCloud.value.id, nextHidden)
await profileStore.updateCloudVisibility(viewedIdentifier.value, selectedCloud.value.id, nextHidden)
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
} catch (error) {
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
@@ -356,14 +337,14 @@ async function toggleSelectedCloudPrivacy() {
}
async function deleteCloudByIds(cloudIds: string[]) {
if (!viewedUserId.value || !cloudIds.length) return
if (!viewedIdentifier.value || !cloudIds.length) return
const confirmed = window.confirm(`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`)
if (!confirmed) return
manageError.value = ''
try {
const deletedCount = await profileStore.deleteClouds(viewedUserId.value, cloudIds)
const deletedCount = await profileStore.deleteClouds(viewedIdentifier.value, cloudIds)
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
selectedCloud.value = null
closeEditModal()
@@ -390,13 +371,13 @@ async function loadProfilePage(force = false) {
clearCloudSelection()
selectionMode.value = false
const targetUserId = viewedUserId.value
const targetIdentifier = viewedIdentifier.value
if (!targetUserId) {
if (!targetIdentifier) {
return
}
await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force)
await profileStore.fetchProfilePage(targetIdentifier, isOwnProfile.value, force)
if (isOwnProfile.value) {
await cloudsStore.fetchCloudTypes()
@@ -491,7 +472,7 @@ watch(selectedUploadDate, async newValue => {
</RouterLink>
</div>
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
<span v-if="isOwnProfile">注册于 {{ formatDate(profileData?.created_at || null) }}</span>
</div>
</div>
</div>
@@ -564,7 +545,7 @@ watch(selectedUploadDate, async newValue => {
<div>
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
<p class="oc-field-help">
按拍摄时间倒序展示,并按月份分组。热力图统计按上传日期计算。
按拍摄时间展示;未记录拍摄时间时回退为上传时间。热力图按上传日期计算。
</p>
</div>
<div class="flex items-center gap-3">
@@ -669,8 +650,8 @@ watch(selectedUploadDate, async newValue => {
/>
选择
</label>
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[item.cloudTypeRarity].chip">
{{ rarityMeta[item.cloudTypeRarity].label }}
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityChip">
稀有度 {{ item.cloudTypeRarity }}
</span>
<span
v-if="isOwnProfile"
@@ -695,7 +676,7 @@ watch(selectedUploadDate, async newValue => {
<div class="p-5">
<p class="truncate text-sm font-medium text-slate-900">
{{ item.location_name || '未填写位置名称' }}
纬度 {{ formatCoordinate(item.latitude) }} / 经度 {{ formatCoordinate(item.longitude) }}
</p>
<p class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
{{ item.description || '没有留下额外说明。' }}
@@ -707,7 +688,7 @@ watch(selectedUploadDate, async newValue => {
</div>
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '该用户暂无公开云图')">
<template #extra>
<RouterLink
v-if="isOwnProfile && !selectedUploadDate"
@@ -731,9 +712,9 @@ watch(selectedUploadDate, async newValue => {
:thumbnail-url="selectedCloud.thumbnail_url"
:image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName"
:subtitle="`拍摄时间:${formatCloudTime(selectedCloud)}`"
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
:subtitle="formatCloudTime(selectedCloud)"
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
:badge-class="rarityChip"
@close="selectedCloud = null"
>
<div class="grid gap-4 sm:grid-cols-2">
@@ -747,7 +728,7 @@ watch(selectedUploadDate, async newValue => {
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">模糊化经纬度</p>
+18 -110
View File
@@ -1,23 +1,19 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { NAlert, NButton, NCard, NIcon, NProgress, NResult, NTag } from 'naive-ui'
import { NAlert, NButton, NCard, NIcon, NProgress, NResult } from 'naive-ui'
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
import { useRouter } from 'vue-router'
import { useCloudsStore } from '@/stores/clouds'
import { useAuthStore } from '@/stores/auth'
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
import { useUpload } from '@/composables/useUpload'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
const router = useRouter()
const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload()
const activeId = ref<string | null>(null)
const dragOver = ref(false)
const successMsg = ref(false)
const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges']>([])
const errorMsg = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const mapPickerOpen = ref(false)
@@ -87,10 +83,6 @@ function confirmMapPicker(payload: { latitude: number; longitude: number }) {
closeMapPicker()
}
function clearCoordinates() {
updateActiveCoordinates(null, null)
}
function updateActivePublicState(isPublic: boolean) {
if (!activeItem.value) return
activeItem.value.isHidden = !isPublic
@@ -112,43 +104,17 @@ async function handleSubmit() {
const result = await uploadAll()
if (result.ok) {
unlockedBadges.value = result.unlockedBadges
successMsg.value = true
} else {
errorMsg.value = '上传失败,请稍后重试'
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
}
}
function resetAfterSuccess() {
successMsg.value = false
unlockedBadges.value = []
errorMsg.value = ''
}
async function saveBadge(badge: NonNullable<typeof unlockedBadges.value[number]>) {
await downloadCloudBadgeCard({
cloudName: badge.cloudName,
cloudNameEn: badge.cloudNameEn,
unlockedAt: badge.unlockedAt,
username: authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户',
rarity: badge.rarity,
})
}
function rarityLabel(rarity: NonNullable<typeof unlockedBadges.value[number]>['rarity']) {
if (rarity === 'common') return '常见'
if (rarity === 'uncommon') return '少见'
return '罕见'
}
function formatUnlockedAt(iso: string) {
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function onLatInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (activeItem.value) {
@@ -168,6 +134,7 @@ function onLngInput(e: Event) {
}
function formatDatetimeLocal(iso: string): string {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
@@ -175,8 +142,8 @@ function formatDatetimeLocal(iso: string): string {
function onCapturedAtChange(e: Event) {
const val = (e.target as HTMLInputElement).value
if (activeItem.value && val) {
activeItem.value.capturedAt = new Date(val).toISOString()
if (activeItem.value) {
activeItem.value.capturedAt = val ? new Date(val).toISOString() : ''
if (activeItem.value.errors.capturedAt) {
delete activeItem.value.errors.capturedAt
}
@@ -186,9 +153,7 @@ function onCapturedAtChange(e: Event) {
function onCategoryChange(e: Event) {
const val = (e.target as HTMLSelectElement).value
if (!activeItem.value) return
if (val === 'other') {
activeItem.value.cloudCategoryId = 'other'
} else if (val === '') {
if (val === '') {
activeItem.value.cloudCategoryId = null
} else {
activeItem.value.cloudCategoryId = Number(val)
@@ -209,48 +174,21 @@ onUnmounted(() => {
<p class="oc-page-eyebrow">Cloud Upload</p>
<h1 class="oc-page-title">上传云图</h1>
<p class="oc-page-description">
批量整理你的云图记录。类别和拍摄时间必填,经纬度既可以手动输入,也可以在迷你地图里点选回填。
批量整理你的云图记录。类别和经纬度必填;拍摄时间可留空,也可以从图片 EXIF 自动读取。
</p>
</div>
</section>
<div class="oc-container oc-container--content oc-page-content">
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
<input ref="fileInput" type="file" accept="image/jpeg,image/png,image/webp" multiple class="hidden" @change="handleFileSelect" />
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
<div class="w-full max-w-5xl">
<NResult status="success" title="已提交审核" />
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<NCard
v-for="badge in unlockedBadges"
:key="badge.cloudTypeId"
class="oc-panel-card border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)]"
>
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white">
{{ badge.cloudName.slice(0, 1) }}
</div>
<div class="mt-5">
<div class="flex items-center gap-3">
<h3 class="text-2xl font-bold text-slate-900">{{ badge.cloudName }}</h3>
<NTag :bordered="false" type="warning">
{{ rarityLabel(badge.rarity) }}
</NTag>
</div>
<p class="mt-1 text-sm text-slate-500">{{ badge.cloudNameEn }}</p>
<p class="mt-4 text-sm text-slate-600">解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}</p>
</div>
<div class="mt-6 flex gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
</div>
</NCard>
</div>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="resetAfterSuccess">继续上传</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往图鉴</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往百科</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="router.push('/profile')">返回个人主页</NButton>
</div>
</div>
@@ -283,7 +221,7 @@ onUnmounted(() => {
>
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
<p class="text-sm text-slate-400">支持 JPG、PNG 格式,可一次选择多张</p>
<p class="text-sm text-slate-400">支持 JPEG、PNG、WebP,单张不超过 20MB,可一次选择多张</p>
</div>
<template v-else>
@@ -343,25 +281,14 @@ onUnmounted(() => {
<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="oc-field-control"
/>
</div>
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
<p v-if="activeItem.errors.file" class="oc-field-error">{{ activeItem.errors.file }}</p>
</div>
<!-- 拍摄时间 * -->
<!-- 拍摄时间 -->
<div>
<label class="oc-field-label">
拍摄时间 <span class="oc-field-required">*</span>
</label>
<label class="oc-field-label">拍摄时间</label>
<input
type="datetime-local"
:value="formatDatetimeLocal(activeItem.capturedAt)"
@@ -371,19 +298,9 @@ onUnmounted(() => {
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
</div>
<!-- 经纬度 -->
<!-- 经纬度 * -->
<div>
<div class="mb-1 flex items-center justify-between gap-3">
<label class="oc-field-label mb-0">经纬度</label>
<button
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
type="button"
@click="clearCoordinates"
class="oc-text-button oc-text-button--muted text-xs"
>
清空
</button>
</div>
<label class="oc-field-label">经纬度 <span class="oc-field-required">*</span></label>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<div>
<input
@@ -419,18 +336,7 @@ onUnmounted(() => {
</button>
</div>
</div>
<p class="mt-1 text-xs text-slate-400">选填,可手动输入,或点击右侧小地图图标选择</p>
</div>
<!-- 位置名称 -->
<div>
<label class="oc-field-label">位置名称</label>
<input
v-model="activeItem.locationName"
type="text"
placeholder="如:北京、成都"
class="oc-field-control"
/>
<p class="mt-1 text-xs text-slate-400">必填,可手动输入,或点击右侧地图图标选择</p>
</div>
<!-- 图片描述 -->
@@ -439,9 +345,11 @@ onUnmounted(() => {
<textarea
v-model="activeItem.description"
rows="3"
maxlength="128"
placeholder="描述一下这张图片..."
class="oc-field-control resize-none"
></textarea>
<p v-if="activeItem.errors.description" class="oc-field-error">{{ activeItem.errors.description }}</p>
</div>
<!-- 公开状态 -->