feat: add cloud encyclopedia system
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export interface UploadItem {
|
||||
id: string
|
||||
@@ -16,6 +17,19 @@ export interface UploadItem {
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
export interface UnlockedBadge {
|
||||
cloudTypeId: number
|
||||
cloudName: string
|
||||
cloudNameEn: string
|
||||
rarity: CloudType['rarity']
|
||||
unlockedAt: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
ok: boolean
|
||||
unlockedBadges: UnlockedBadge[]
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
@@ -75,6 +89,50 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchUnlockedTypeIds(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('user_collections')
|
||||
.select('cloud_type_id')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return new Set(
|
||||
(data || [])
|
||||
.map(row => row.cloud_type_id)
|
||||
.filter((cloudTypeId): cloudTypeId is number => typeof cloudTypeId === 'number'),
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchBadgeDetails(unlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }>) {
|
||||
const ids = unlockedRows.map(item => item.cloudTypeId)
|
||||
const { data, error } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('id,name,name_en,rarity')
|
||||
.in('id', ids)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const typeMap = new Map(
|
||||
((data || []) as Array<Pick<CloudType, 'id' | 'name' | 'name_en' | 'rarity'>>).map(item => [item.id, item]),
|
||||
)
|
||||
|
||||
return unlockedRows
|
||||
.map(item => {
|
||||
const cloudType = typeMap.get(item.cloudTypeId)
|
||||
if (!cloudType) return null
|
||||
|
||||
return {
|
||||
cloudTypeId: cloudType.id,
|
||||
cloudName: cloudType.name,
|
||||
cloudNameEn: cloudType.name_en,
|
||||
rarity: cloudType.rarity,
|
||||
unlockedAt: item.unlockedAt,
|
||||
} satisfies UnlockedBadge
|
||||
})
|
||||
.filter((item): item is UnlockedBadge => item !== null)
|
||||
}
|
||||
|
||||
export function useUpload() {
|
||||
const items = ref<UploadItem[]>([])
|
||||
const uploading = ref(false)
|
||||
@@ -163,8 +221,10 @@ export function useUpload() {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
async function uploadAll(): Promise<boolean> {
|
||||
if (!validateAll()) return false
|
||||
async function uploadAll(): Promise<UploadResult> {
|
||||
if (!validateAll()) {
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
totalItems.value = items.value.length
|
||||
@@ -174,10 +234,19 @@ export function useUpload() {
|
||||
const userId = (await supabase.auth.getUser()).data.user?.id
|
||||
if (!userId) {
|
||||
uploading.value = false
|
||||
return false
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
let unlockedTypeIds = new Set<number>()
|
||||
try {
|
||||
unlockedTypeIds = await fetchUnlockedTypeIds(userId)
|
||||
} catch {
|
||||
unlockedTypeIds = new Set<number>()
|
||||
}
|
||||
|
||||
const newlyUnlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }> = []
|
||||
|
||||
for (let i = 0; i < items.value.length; i++) {
|
||||
const item = items.value[i]
|
||||
currentItemIndex.value = i + 1
|
||||
@@ -200,7 +269,7 @@ export function useUpload() {
|
||||
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
||||
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
||||
|
||||
const { error: dbError } = await supabase
|
||||
const { data: insertedCloud, error: dbError } = await supabase
|
||||
.from('clouds')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
@@ -216,8 +285,41 @@ export function useUpload() {
|
||||
status: 'approved',
|
||||
is_hidden: item.isHidden,
|
||||
})
|
||||
.select('id,cloud_type_id')
|
||||
.single()
|
||||
if (dbError) throw dbError
|
||||
|
||||
if (
|
||||
insertedCloud &&
|
||||
typeof insertedCloud.cloud_type_id === 'number' &&
|
||||
!unlockedTypeIds.has(insertedCloud.cloud_type_id)
|
||||
) {
|
||||
try {
|
||||
const { data: collectionRow, error: collectionError } = await supabase
|
||||
.from('user_collections')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
cloud_type_id: insertedCloud.cloud_type_id,
|
||||
first_cloud_id: insertedCloud.id,
|
||||
})
|
||||
.select('cloud_type_id,unlocked_at')
|
||||
.single()
|
||||
|
||||
if (collectionError) {
|
||||
const duplicate = collectionError.code === '23505'
|
||||
if (!duplicate) throw collectionError
|
||||
} else if (collectionRow) {
|
||||
unlockedTypeIds.add(collectionRow.cloud_type_id)
|
||||
newlyUnlockedRows.push({
|
||||
cloudTypeId: collectionRow.cloud_type_id,
|
||||
unlockedAt: collectionRow.unlocked_at,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore collection sync failures so uploads can still complete.
|
||||
}
|
||||
}
|
||||
|
||||
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
|
||||
}
|
||||
|
||||
@@ -225,9 +327,12 @@ export function useUpload() {
|
||||
URL.revokeObjectURL(item.preview)
|
||||
}
|
||||
items.value = []
|
||||
return true
|
||||
return {
|
||||
ok: true,
|
||||
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
export type BadgeRarity = 'common' | 'uncommon' | 'rare'
|
||||
|
||||
export interface CloudBadgeCardInput {
|
||||
cloudName: string
|
||||
cloudNameEn: string
|
||||
unlockedAt: string
|
||||
username: string
|
||||
rarity: BadgeRarity
|
||||
}
|
||||
|
||||
const rarityTheme = {
|
||||
common: {
|
||||
from: '#dbeafe',
|
||||
to: '#f8fafc',
|
||||
accent: '#0284c7',
|
||||
shadow: 'rgba(2, 132, 199, 0.18)',
|
||||
},
|
||||
uncommon: {
|
||||
from: '#fef3c7',
|
||||
to: '#fff7ed',
|
||||
accent: '#d97706',
|
||||
shadow: 'rgba(217, 119, 6, 0.22)',
|
||||
},
|
||||
rare: {
|
||||
from: '#fee2e2',
|
||||
to: '#fff7ed',
|
||||
accent: '#dc2626',
|
||||
shadow: 'rgba(220, 38, 38, 0.22)',
|
||||
},
|
||||
} satisfies Record<BadgeRarity, { from: string; to: string; accent: string; shadow: string }>
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function makeDownloadName(name: string) {
|
||||
return `opencloud-badge-${name}.png`
|
||||
}
|
||||
|
||||
export async function downloadCloudBadgeCard(input: CloudBadgeCardInput) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 1200
|
||||
canvas.height = 630
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const theme = rarityTheme[input.rarity]
|
||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
gradient.addColorStop(0, theme.from)
|
||||
gradient.addColorStop(1, theme.to)
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.72)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(1010, 120, 140, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(160, 520, 180, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#0f172a'
|
||||
ctx.font = '700 44px sans-serif'
|
||||
ctx.fillText('OpenCloud 云朵图鉴', 72, 88)
|
||||
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
|
||||
ctx.font = '400 24px sans-serif'
|
||||
ctx.fillText('新徽章已解锁', 72, 126)
|
||||
|
||||
ctx.shadowColor = theme.shadow
|
||||
ctx.shadowBlur = 28
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(72, 170, 1056, 380, 32)
|
||||
ctx.fill()
|
||||
ctx.shadowBlur = 0
|
||||
|
||||
ctx.fillStyle = theme.accent
|
||||
ctx.beginPath()
|
||||
ctx.arc(210, 360, 92, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '700 82px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(input.cloudName.slice(0, 1), 210, 360)
|
||||
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.fillStyle = '#0f172a'
|
||||
ctx.font = '700 64px sans-serif'
|
||||
ctx.fillText(input.cloudName, 340, 300)
|
||||
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
|
||||
ctx.font = '400 30px sans-serif'
|
||||
ctx.fillText(input.cloudNameEn, 340, 346)
|
||||
|
||||
ctx.fillStyle = theme.accent
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(340, 382, 168, 48, 24)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '600 24px sans-serif'
|
||||
ctx.fillText(input.rarity === 'common' ? '常见' : input.rarity === 'uncommon' ? '少见' : '罕见', 384, 414)
|
||||
|
||||
ctx.fillStyle = '#334155'
|
||||
ctx.font = '400 28px sans-serif'
|
||||
ctx.fillText(`解锁者:${input.username}`, 340, 476)
|
||||
ctx.fillText(`解锁日期:${formatDate(input.unlockedAt)}`, 340, 518)
|
||||
|
||||
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
|
||||
if (!blob) return
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = makeDownloadName(input.cloudNameEn.toLowerCase())
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { CloudType, UserCollection } from '@/types/database'
|
||||
|
||||
export interface CollectionPreviewCloud {
|
||||
id: string
|
||||
image_url: string
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
location_name: string | null
|
||||
}
|
||||
|
||||
export interface CollectionEntry extends UserCollection {
|
||||
firstCloud: CollectionPreviewCloud | null
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
return '图鉴收藏加载失败'
|
||||
}
|
||||
|
||||
function toCollectionCloudMap(rows: Array<Record<string, unknown>> | null) {
|
||||
return new Map(
|
||||
(rows || []).map(row => [
|
||||
row.id as string,
|
||||
{
|
||||
id: row.id as string,
|
||||
image_url: row.image_url as string,
|
||||
captured_at: (row.captured_at as string | null) ?? null,
|
||||
created_at: row.created_at as string,
|
||||
location_name: (row.location_name as string | null) ?? null,
|
||||
} satisfies CollectionPreviewCloud,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const cloudTypes = ref<CloudType[]>([])
|
||||
const myCollection = ref<CollectionEntry[]>([])
|
||||
const loadingCloudTypes = ref(false)
|
||||
const loadingCollection = ref(false)
|
||||
const cloudTypesLoaded = ref(false)
|
||||
const collectionLoadedForUserId = ref<string | null>(null)
|
||||
const collectionError = ref('')
|
||||
|
||||
const collectionMap = computed(() => {
|
||||
return new Map(myCollection.value.map(item => [item.cloud_type_id, item]))
|
||||
})
|
||||
|
||||
const unlockedCount = computed(() => myCollection.value.length)
|
||||
const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`)
|
||||
const unlockPercent = computed(() => {
|
||||
if (!cloudTypes.value.length) return 0
|
||||
return Math.round(unlockedCount.value / cloudTypes.value.length * 100)
|
||||
})
|
||||
|
||||
async function fetchCloudTypes(force = false) {
|
||||
if (cloudTypesLoaded.value && !force) return
|
||||
|
||||
loadingCloudTypes.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('*')
|
||||
.order('id')
|
||||
|
||||
if (error) throw error
|
||||
|
||||
cloudTypes.value = (data || []) as CloudType[]
|
||||
cloudTypesLoaded.value = true
|
||||
} finally {
|
||||
loadingCloudTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMyCollection(force = false) {
|
||||
const userId = authStore.user?.id ?? null
|
||||
|
||||
if (!userId) {
|
||||
myCollection.value = []
|
||||
collectionLoadedForUserId.value = null
|
||||
collectionError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && collectionLoadedForUserId.value === userId) return
|
||||
|
||||
loadingCollection.value = true
|
||||
collectionError.value = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_collections')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('unlocked_at', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const collectionRows = (data || []) as UserCollection[]
|
||||
const firstCloudIds = collectionRows
|
||||
.map(item => item.first_cloud_id)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
let firstCloudMap = new Map<string, CollectionPreviewCloud>()
|
||||
|
||||
if (firstCloudIds.length) {
|
||||
const { data: firstCloudData, error: firstCloudError } = await supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,captured_at,created_at,location_name')
|
||||
.in('id', firstCloudIds)
|
||||
|
||||
if (firstCloudError) throw firstCloudError
|
||||
|
||||
firstCloudMap = toCollectionCloudMap(firstCloudData as Array<Record<string, unknown>> | null)
|
||||
}
|
||||
|
||||
myCollection.value = collectionRows.map(item => ({
|
||||
...item,
|
||||
firstCloud: item.first_cloud_id ? firstCloudMap.get(item.first_cloud_id) ?? null : null,
|
||||
}))
|
||||
collectionLoadedForUserId.value = userId
|
||||
} catch (error) {
|
||||
myCollection.value = []
|
||||
collectionLoadedForUserId.value = userId
|
||||
collectionError.value = getErrorMessage(error)
|
||||
} finally {
|
||||
loadingCollection.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isUnlocked(cloudTypeId: number) {
|
||||
return collectionMap.value.has(cloudTypeId)
|
||||
}
|
||||
|
||||
function getCollectionEntry(cloudTypeId: number) {
|
||||
return collectionMap.value.get(cloudTypeId) ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
cloudTypes,
|
||||
myCollection,
|
||||
loadingCloudTypes,
|
||||
loadingCollection,
|
||||
collectionError,
|
||||
unlockedCount,
|
||||
unlockProgress,
|
||||
unlockPercent,
|
||||
fetchCloudTypes,
|
||||
fetchMyCollection,
|
||||
isUnlocked,
|
||||
getCollectionEntry,
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
interface CloudGalleryItem {
|
||||
id: string
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
profiles: {
|
||||
username: string
|
||||
} | null
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const encyclopediaStore = useEncyclopediaStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const gallery = ref<CloudGalleryItem[]>([])
|
||||
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 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)
|
||||
}
|
||||
|
||||
async function loadGallery(typeId: number) {
|
||||
const galleryQuery = supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,location_name,description,captured_at,created_at,profiles(username)')
|
||||
.eq('cloud_type_id', typeId)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
.order('captured_at', { ascending: false, nullsFirst: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(24)
|
||||
|
||||
const countQuery = supabase
|
||||
.from('clouds')
|
||||
.select('*', { head: true, count: 'exact' })
|
||||
.eq('cloud_type_id', typeId)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
|
||||
const [{ data: galleryData, error: galleryError }, { count, error: countError }] = await Promise.all([
|
||||
galleryQuery,
|
||||
countQuery,
|
||||
])
|
||||
|
||||
if (galleryError) throw galleryError
|
||||
if (countError) throw countError
|
||||
|
||||
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => {
|
||||
const profiles = Array.isArray(row.profiles) ? row.profiles : []
|
||||
const profile = profiles[0] as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
id: row.id as string,
|
||||
image_url: row.image_url as string,
|
||||
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||
location_name: (row.location_name as string | null) ?? null,
|
||||
description: (row.description as string | null) ?? null,
|
||||
captured_at: (row.captured_at as string | null) ?? null,
|
||||
created_at: row.created_at as string,
|
||||
profiles: profile ? { username: profile.username as string } : null,
|
||||
} satisfies CloudGalleryItem
|
||||
})
|
||||
publicCount.value = count || 0
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
if (!Number.isFinite(cloudTypeId.value) || cloudTypeId.value <= 0) {
|
||||
loadError.value = '云型不存在'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
await encyclopediaStore.fetchCloudTypes()
|
||||
await encyclopediaStore.fetchMyCollection()
|
||||
|
||||
if (!cloudType.value) {
|
||||
loadError.value = '云型不存在'
|
||||
gallery.value = []
|
||||
publicCount.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
await loadGallery(cloudType.value.id)
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '详情加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPage)
|
||||
watch(() => route.params.id, loadPage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto px-4 py-12">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">云型详情</h1>
|
||||
<p class="text-gray-500">详情页开发中...</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="space-y-6">
|
||||
<div class="h-80 animate-pulse rounded-[32px] bg-slate-200"></div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="rounded-[28px] border border-red-200 bg-red-50 p-6 text-red-700">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="cloudType">
|
||||
<section class="overflow-hidden rounded-[32px] 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
|
||||
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">
|
||||
<span class="inline-flex rounded-full border px-3 py-1 text-xs font-medium backdrop-blur" :class="rarityMeta[cloudType.rarity].chip">
|
||||
{{ rarityMeta[cloudType.rarity].label }}
|
||||
</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>
|
||||
</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="mt-8 rounded-[28px] border border-slate-200 bg-white p-5">
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-slate-600">首次解锁于 {{ formatDate(collectionEntry.unlocked_at) }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="mt-2 text-2xl font-bold text-slate-900">尚未解锁</p>
|
||||
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后,这枚徽章就会被点亮。</p>
|
||||
</template>
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</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="mt-1 text-sm text-slate-500">来自社区公开可见的已通过云图。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="gallery.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
v-for="item in gallery"
|
||||
:key="item.id"
|
||||
class="overflow-hidden rounded-[28px] 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">
|
||||
<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>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
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 }>
|
||||
|
||||
const totalTypes = computed(() => encyclopediaStore.cloudTypes.length || 10)
|
||||
const unlockedCount = computed(() => (authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0))
|
||||
const progressText = computed(() => `${unlockedCount.value}/${totalTypes.value}`)
|
||||
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-4 py-12">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">☁️ 云朵图鉴</h1>
|
||||
<p class="text-gray-500">图鉴功能开发中...</p>
|
||||
<div class="relative">
|
||||
<div class="bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] border-b border-sky-100">
|
||||
<div class="max-w-6xl mx-auto px-4 py-10">
|
||||
<div class="grid gap-6 lg:grid-cols-[1.4fr_0.9fr] lg:items-end">
|
||||
<div>
|
||||
<p class="text-sm font-medium tracking-[0.24em] text-sky-700 uppercase">Cloud Encyclopedia</p>
|
||||
<h1 class="mt-3 text-4xl font-bold text-slate-900">云朵图鉴</h1>
|
||||
<p class="mt-4 max-w-2xl text-slate-600 leading-7">
|
||||
收集 10 种基础云属。每拍到一种新的云型,图鉴里就会点亮一枚徽章,并记录你第一次遇见它的时间。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[28px] border border-white/80 bg-white/80 p-6 shadow-sm backdrop-blur">
|
||||
<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">
|
||||
{{ 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>
|
||||
|
||||
<p class="mt-4 text-sm text-slate-500">
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
已解锁 {{ unlockedCount }} 枚徽章,还差 {{ Math.max(totalTypes - unlockedCount, 0) }} 枚。
|
||||
</template>
|
||||
<template v-else>
|
||||
登录后可同步你的个人图鉴进度。
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</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">
|
||||
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-else 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="isUnlocked(cloudType.id) ? 'border-amber-300 shadow-amber-100/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
>
|
||||
<div
|
||||
class="relative h-44 overflow-hidden border-b"
|
||||
:class="isUnlocked(cloudType.id) ? 'border-white/40' : 'border-slate-200'"
|
||||
>
|
||||
<img
|
||||
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
|
||||
:src="getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
|
||||
:alt="cloudType.name"
|
||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
:class="isUnlocked(cloudType.id) ? '' : 'grayscale'"
|
||||
/>
|
||||
<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-300 to-slate-100'"
|
||||
>
|
||||
<span class="text-6xl font-bold text-slate-800/85">{{ cloudType.name.slice(0, 1) }}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{{ rarityMeta[cloudType.rarity].label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-4 left-4 right-4 text-white">
|
||||
<p class="text-xl font-semibold">{{ cloudType.name }}</p>
|
||||
<p class="mt-1 text-sm text-white/80">{{ cloudType.name_en }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<p class="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 text-sm">
|
||||
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
|
||||
<span class="font-medium text-amber-700">已收录</span>
|
||||
<span class="text-slate-500">首次记录于 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-medium text-slate-700">等待发现</span>
|
||||
<span class="text-slate-400">拍到后点亮</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</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"
|
||||
>
|
||||
{{ lockHint }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
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'
|
||||
|
||||
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)
|
||||
|
||||
@@ -68,15 +72,45 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
const ok = await uploadAll()
|
||||
if (ok) {
|
||||
const result = await uploadAll()
|
||||
if (result.ok) {
|
||||
unlockedBadges.value = result.unlockedBadges
|
||||
successMsg.value = true
|
||||
setTimeout(() => router.push('/profile'), 2000)
|
||||
} else {
|
||||
errorMsg.value = '上传失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -133,10 +167,77 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
||||
<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-2xl font-bold text-gray-900 mb-2">上传成功!</h2>
|
||||
<p class="text-gray-500">正在跳转到个人主页...</p>
|
||||
<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>
|
||||
|
||||
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
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"
|
||||
>
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-2xl 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">
|
||||
{{ rarityLabel(badge.rarity) }}
|
||||
</span>
|
||||
</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>
|
||||
</div>
|
||||
</article>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user