feat: enhance cloud image management

This commit is contained in:
2026-05-22 10:34:35 +08:00
parent 7cdf07447c
commit 7e4ee3d699
11 changed files with 1468 additions and 216 deletions
+272
View File
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NAlert, NButton } from 'naive-ui'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
import type { CloudType } from '@/types/database'
export interface CloudEditFormValue {
cloudCategoryId: number | 'other' | null
customCloudType: string
capturedAt: string
latitude: number | null
longitude: number | null
locationName: string
description: string
isPublic: boolean
}
const props = withDefaults(defineProps<{
open: boolean
saving?: boolean
error?: string
cloudTypes: CloudType[]
initialValue: CloudEditFormValue | null
}>(), {
saving: false,
error: '',
})
const emit = defineEmits<{
close: []
save: [value: CloudEditFormValue]
}>()
const form = ref<CloudEditFormValue>(createEmptyForm())
const mapPickerOpen = ref(false)
const hasCoordinates = computed(() => form.value.latitude !== null || form.value.longitude !== null)
function createEmptyForm(): CloudEditFormValue {
return {
cloudCategoryId: null,
customCloudType: '',
capturedAt: '',
latitude: null,
longitude: null,
locationName: '',
description: '',
isPublic: true,
}
}
function cloneForm(value: CloudEditFormValue | null) {
return value ? { ...value } : createEmptyForm()
}
function onCategoryChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value === 'other') {
form.value.cloudCategoryId = 'other'
} else if (!value) {
form.value.cloudCategoryId = null
} else {
form.value.cloudCategoryId = Number(value)
}
}
function onLatInput(event: Event) {
const value = Number.parseFloat((event.target as HTMLInputElement).value)
form.value.latitude = Number.isNaN(value) ? null : value
}
function onLngInput(event: Event) {
const value = Number.parseFloat((event.target as HTMLInputElement).value)
form.value.longitude = Number.isNaN(value) ? null : value
}
function clearCoordinates() {
form.value.latitude = null
form.value.longitude = null
}
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
form.value.latitude = payload.latitude
form.value.longitude = payload.longitude
mapPickerOpen.value = false
}
function close() {
emit('close')
}
function save() {
emit('save', { ...form.value })
}
watch(() => props.open, open => {
if (open) {
form.value = cloneForm(props.initialValue)
} else {
mapPickerOpen.value = false
}
})
watch(() => props.initialValue, value => {
if (props.open) {
form.value = cloneForm(value)
}
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
@click="close"
>
<div class="w-full max-w-2xl bg-white shadow-2xl" @click.stop>
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
<div>
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
<p class="mt-1 text-sm text-slate-500">修改后会同步影响个人主页、画廊和地图中的展示。</p>
</div>
<button
type="button"
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
@click="close"
>
✕
</button>
</div>
<div class="max-h-[70vh] overflow-y-auto px-6 py-5">
<NAlert
v-if="error"
class="mb-5"
type="error"
:show-icon="false"
:bordered="false"
>
{{ error }}
</NAlert>
<div class="grid gap-5">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">类别</label>
<select
:value="form.cloudCategoryId ?? ''"
class="w-full border border-slate-300 bg-white px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
@change="onCategoryChange"
>
<option value="" disabled>请选择</option>
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
{{ ct.name }}({{ ct.name_en }})
</option>
<option value="other">其他</option>
</select>
</div>
<div v-if="form.cloudCategoryId === 'other'">
<label class="mb-1 block text-sm font-medium text-slate-700">自定义云型</label>
<input
v-model="form.customCloudType"
type="text"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">拍摄时间</label>
<input
v-model="form.capturedAt"
type="datetime-local"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">位置名称</label>
<input
v-model="form.locationName"
type="text"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="如:北京、成都"
/>
</div>
<div>
<div class="mb-1 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-slate-700">经纬度</label>
<button
v-if="hasCoordinates"
type="button"
class="text-xs font-medium text-slate-400 transition-colors hover:text-slate-700"
@click="clearCoordinates"
>
清空
</button>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<input
:value="form.latitude !== null ? form.latitude : ''"
type="number"
step="any"
placeholder="纬度"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
@input="onLatInput"
/>
<input
:value="form.longitude !== null ? form.longitude : ''"
type="number"
step="any"
placeholder="经度"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
@input="onLngInput"
/>
<button
type="button"
class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
title="地图选点"
aria-label="地图选点"
@click="mapPickerOpen = true"
>
<span class="text-lg leading-none">🗺️</span>
</button>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">图片描述</label>
<textarea
v-model="form.description"
rows="4"
class="w-full resize-none border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="描述一下这张图片..."
></textarea>
</div>
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
<span>
<span class="block text-sm font-medium text-slate-800">公开展示</span>
<span class="mt-1 block text-xs text-slate-500">关闭后不会出现在画廊、地图和公开主页中。</span>
</span>
<input
v-model="form.isPublic"
type="checkbox"
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
/>
</label>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4">
<NButton secondary strong @click="close">取消</NButton>
<NButton type="primary" secondary strong :loading="saving" @click="save">
保存修改
</NButton>
</div>
</div>
</div>
</Teleport>
<MapPickerModal
:open="mapPickerOpen"
:latitude="form.latitude"
:longitude="form.longitude"
description="点击地图即可回填这张图片的经纬度。"
footer-text="保存时会继续做模糊化处理。"
@close="mapPickerOpen = false"
@confirm="confirmMapPicker"
/>
</template>
+188 -23
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
open: boolean
imageUrl: string
thumbnailUrl?: string | null
imageAlt: string
title: string
subtitle?: string
@@ -12,9 +13,10 @@ const props = withDefaults(defineProps<{
panelWidth?: string
}>(), {
subtitle: '',
thumbnailUrl: null,
badgeLabel: '',
badgeClass: '',
panelWidth: '20rem',
panelWidth: '',
})
const emit = defineEmits<{
@@ -22,23 +24,112 @@ const emit = defineEmits<{
}>()
const collapsed = ref(false)
const imageLoaded = ref(false)
const previewLoaded = ref(false)
const naturalWidth = ref(0)
const naturalHeight = ref(0)
const viewportWidth = ref(0)
const viewportHeight = ref(0)
const isDesktop = computed(() => viewportWidth.value >= 1024)
function clampPanelWidth(width: number) {
return Math.min(Math.max(width, 240), 400)
}
function parsePanelWidth(width: string) {
const normalized = width.trim()
if (!normalized) return null
if (normalized.endsWith('px')) return Number.parseFloat(normalized)
if (normalized.endsWith('rem')) return Number.parseFloat(normalized) * 16
return null
}
function updateViewport() {
if (typeof window === 'undefined') return
viewportWidth.value = window.innerWidth
viewportHeight.value = window.innerHeight
}
const layout = computed(() => {
const aspectRatio = naturalWidth.value > 0 && naturalHeight.value > 0
? naturalWidth.value / naturalHeight.value
: 4 / 3
const maxModalWidth = Math.max(viewportWidth.value * 0.92, 320)
const mobileImageMaxHeight = Math.max(viewportHeight.value * 0.48, 220)
const desktopImageMaxHeight = Math.max(viewportHeight.value * 0.8, 240)
if (!isDesktop.value) {
const imageWidth = Math.min(maxModalWidth, mobileImageMaxHeight * aspectRatio)
const imageHeight = imageWidth / aspectRatio
const panelHeight = collapsed.value ? 0 : Math.min(Math.max(imageHeight * 0.72, 180), viewportHeight.value * 0.34)
return {
imageWidth,
imageHeight,
panelWidth: imageWidth,
panelHeight,
}
}
const maxImageHeight = desktopImageMaxHeight
if (collapsed.value) {
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
return {
imageWidth,
imageHeight: imageWidth / aspectRatio,
panelWidth: 0,
panelHeight: imageWidth / aspectRatio,
}
}
const fixedPanelWidth = parsePanelWidth(props.panelWidth)
let panelWidth = fixedPanelWidth ?? clampPanelWidth(Math.min(maxModalWidth * 0.28, 320))
let imageWidth = Math.min(maxModalWidth - panelWidth, maxImageHeight * aspectRatio)
if (!fixedPanelWidth) {
for (let index = 0; index < 4; index += 1) {
panelWidth = clampPanelWidth(imageWidth * 0.34)
imageWidth = Math.min(maxModalWidth - panelWidth, maxImageHeight * aspectRatio)
}
}
return {
imageWidth,
imageHeight: imageWidth / aspectRatio,
panelWidth,
panelHeight: imageWidth / aspectRatio,
}
})
const rootStyle = computed(() => ({
'--detail-panel-width': props.panelWidth,
'--detail-panel-width': `${Math.round(layout.value.panelWidth)}px`,
'--detail-panel-height': `${Math.round(layout.value.panelHeight)}px`,
}) as Record<string, string>)
const imageClass = computed(() => {
return collapsed.value
? 'block h-full w-auto max-w-full object-contain lg:max-w-[92vw]'
: 'block h-full w-auto max-w-full object-contain lg:max-w-[calc(92vw-var(--detail-panel-width))]'
})
const imageStyle = computed(() => ({
width: `${Math.round(layout.value.imageWidth)}px`,
height: `${Math.round(layout.value.imageHeight)}px`,
}) as Record<string, string>)
function resetImageState() {
imageLoaded.value = false
previewLoaded.value = false
naturalWidth.value = 0
naturalHeight.value = 0
}
watch(() => props.open, open => {
if (open) {
collapsed.value = false
resetImageState()
}
})
watch(() => props.imageUrl, () => {
resetImageState()
})
function close() {
emit('close')
}
@@ -46,6 +137,37 @@ function close() {
function togglePanel() {
collapsed.value = !collapsed.value
}
function handleImageLoad(event: Event) {
const target = event.target as HTMLImageElement | null
if (!target) return
naturalWidth.value = target.naturalWidth
naturalHeight.value = target.naturalHeight
imageLoaded.value = true
}
function handlePreviewLoad(event: Event) {
const target = event.target as HTMLImageElement | null
if (!target) return
if (!naturalWidth.value || !naturalHeight.value) {
naturalWidth.value = target.naturalWidth
naturalHeight.value = target.naturalHeight
}
previewLoaded.value = true
}
onMounted(() => {
updateViewport()
if (typeof window !== 'undefined') {
window.addEventListener('resize', updateViewport)
}
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateViewport)
}
})
</script>
<template>
@@ -56,28 +178,67 @@ function togglePanel() {
@click="close"
>
<div
class="relative h-[80vh] w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl lg:flex"
class="relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
:class="isDesktop ? 'lg:flex lg:items-start' : 'flex flex-col'"
:style="rootStyle"
@click.stop
>
<div class="flex min-h-0 flex-1 items-center justify-center overflow-hidden lg:flex-none">
<img :src="imageUrl" :alt="imageAlt" :class="imageClass" />
</div>
<button
v-if="isDesktop"
type="button"
@click="togglePanel"
class="absolute top-1/2 z-30 hidden h-14 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center border border-slate-200 bg-white text-slate-500 shadow-lg transition-colors hover:text-slate-800 lg:flex"
:style="{ left: `${Math.round(layout.imageWidth)}px` }"
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
:title="collapsed ? '展开详细信息' : '收起详细信息'"
>
<span class="text-lg leading-none">{{ collapsed ? '‹' : '›' }}</span>
</button>
<div
class="relative h-[38vh] transition-[width] duration-300 lg:h-full lg:shrink-0"
:class="collapsed ? 'lg:w-0' : 'lg:w-[var(--detail-panel-width)]'"
class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-slate-950 lg:flex-none"
:style="imageStyle"
>
<button
type="button"
@click="togglePanel"
class="absolute left-0 top-1/2 z-10 hidden h-14 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 shadow-lg transition-colors hover:text-slate-800 lg:flex"
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
:title="collapsed ? '展开详细信息' : '收起详细信息'"
>
<span class="text-lg leading-none">{{ collapsed ? '‹' : '›' }}</span>
</button>
<div
v-if="!previewLoaded && !imageLoaded"
class="absolute inset-0 animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)] bg-[length:220%_100%]"
></div>
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
:alt="imageAlt"
class="absolute inset-0 block h-full w-full object-contain blur-[1px] transition-opacity duration-300"
:class="imageLoaded ? 'opacity-0' : 'opacity-100'"
@load="handlePreviewLoad"
/>
<img
:src="imageUrl"
:alt="imageAlt"
class="relative block h-full w-full object-contain transition-opacity duration-300"
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
@load="handleImageLoad"
/>
</div>
<button
v-if="!isDesktop"
type="button"
@click="togglePanel"
class="flex items-center justify-center gap-2 border-t border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
:title="collapsed ? '展开详细信息' : '收起详细信息'"
>
<span>{{ collapsed ? '展开详细信息' : '收起详细信息' }}</span>
<span class="text-base leading-none">{{ collapsed ? '▼' : '▲' }}</span>
</button>
<div
class="relative overflow-hidden transition-[width,height] duration-300"
:class="[
collapsed ? 'h-0 lg:w-0' : 'w-full lg:w-[var(--detail-panel-width)]',
isDesktop ? 'lg:h-[var(--detail-panel-height)] lg:shrink-0' : 'h-[var(--detail-panel-height)]',
]"
>
<div
class="flex h-full flex-col overflow-hidden border-t border-slate-200 bg-white transition-[border-color] duration-300 lg:border-t-0"
:class="collapsed ? 'lg:border-l-0' : 'lg:border-l'"
@@ -109,6 +270,10 @@ function togglePanel() {
<div class="flex-1 overflow-y-auto px-6 py-5">
<slot />
</div>
<div v-if="$slots.actions" class="border-t border-slate-200 px-6 py-4">
<slot name="actions" />
</div>
</div>
</div>
</div>
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { NButton } from 'naive-ui'
import { loadAMap } from '@/lib/amap'
const props = withDefaults(defineProps<{
open: boolean
latitude: number | null
longitude: number | null
title?: string
description?: string
footerText?: string
}>(), {
title: '地图选点',
description: '点击地图即可回填经纬度,也可以继续手动修改。',
footerText: '建议点选大致拍摄位置,保存时会继续做模糊化处理。',
})
const emit = defineEmits<{
close: []
confirm: [payload: { latitude: number; longitude: number }]
}>()
const loading = ref(false)
const errorMsg = ref('')
const pickedLat = ref<number | null>(null)
const pickedLng = ref<number | null>(null)
const mapEl = ref<HTMLDivElement | null>(null)
let mapPickerAMap: typeof AMap | null = null
let mapPickerInstance: AMap.Map | null = null
let mapPickerMarker: AMap.Marker | null = null
function formatCoordinate(value: number | null) {
return value === null ? '未选择' : value.toFixed(6)
}
function destroyMapPicker() {
mapPickerMarker?.setMap(null)
mapPickerMarker = null
mapPickerInstance?.destroy()
mapPickerInstance = null
}
function placeMarker(longitude: number, latitude: number) {
if (!mapPickerAMap || !mapPickerInstance) return
if (!mapPickerMarker) {
mapPickerMarker = new mapPickerAMap.Marker({
position: [longitude, latitude],
title: '拍摄位置',
} as AMap.MarkerOptions)
mapPickerMarker.setMap(mapPickerInstance)
} else {
mapPickerMarker.setPosition([longitude, latitude])
}
}
function extractLngLat(event: unknown) {
const payload = event as { lnglat?: { lng?: number; lat?: number } } | undefined
const lng = payload?.lnglat?.lng
const lat = payload?.lnglat?.lat
if (typeof lng !== 'number' || typeof lat !== 'number') return null
return { lng, lat }
}
function handleMapClick(event: unknown) {
const picked = extractLngLat(event)
if (!picked) return
pickedLng.value = picked.lng
pickedLat.value = picked.lat
placeMarker(picked.lng, picked.lat)
}
async function initializeMap() {
loading.value = true
errorMsg.value = ''
pickedLat.value = props.latitude
pickedLng.value = props.longitude
await nextTick()
destroyMapPicker()
try {
mapPickerAMap = await loadAMap()
const hasCoordinates =
typeof props.latitude === 'number' &&
typeof props.longitude === 'number'
const center: [number, number] = hasCoordinates
? [props.longitude as number, props.latitude as number]
: [104.07, 30.67]
mapPickerInstance = new mapPickerAMap.Map(mapEl.value!, {
viewMode: '2D',
pitch: 0,
rotation: 0,
zoom: hasCoordinates ? 11 : 4,
center,
mapStyle: 'amap://styles/normal',
resizeEnable: true,
} as AMap.MapOptions)
mapPickerInstance.on('click', handleMapClick)
if (hasCoordinates) {
placeMarker(props.longitude as number, props.latitude as number)
}
} catch (error) {
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
} finally {
loading.value = false
}
}
function close() {
emit('close')
}
function confirm() {
if (pickedLat.value === null || pickedLng.value === null) return
emit('confirm', {
latitude: pickedLat.value,
longitude: pickedLng.value,
})
}
watch(() => props.open, open => {
if (open) {
initializeMap()
} else {
loading.value = false
errorMsg.value = ''
destroyMapPicker()
}
})
onBeforeUnmount(() => {
destroyMapPicker()
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
@click="close"
>
<div class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl" @click.stop>
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
<div>
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
<p class="mt-1 text-sm text-slate-500">{{ description }}</p>
</div>
<button
type="button"
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
@click="close"
>
✕
</button>
</div>
<div class="px-6 py-5">
<div class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<span>当前选择:</span>
<span class="font-medium text-slate-900">纬度 {{ formatCoordinate(pickedLat) }}</span>
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(pickedLng) }}</span>
</div>
<div class="relative overflow-hidden border border-slate-200 bg-slate-100">
<div ref="mapEl" class="h-[420px] w-full"></div>
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
>
正在加载地图...
</div>
<div
v-if="errorMsg"
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
>
{{ errorMsg }}
</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4">
<p class="text-sm text-slate-500">{{ footerText }}</p>
<div class="flex gap-3">
<NButton secondary strong @click="close">取消</NButton>
<NButton
type="primary"
secondary
strong
:disabled="pickedLat === null || pickedLng === null"
@click="confirm"
>
使用这个位置
</NButton>
</div>
</div>
</div>
</div>
</Teleport>
</template>
+42 -8
View File
@@ -88,7 +88,7 @@ onMounted(() => {
</script>
<template>
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-16'">
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
<header
class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300"
:class="headerHidden ? '-translate-y-full' : 'translate-y-0'"
@@ -96,7 +96,7 @@ onMounted(() => {
@mouseleave="releasePinnedHeader"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex h-16 items-center justify-between">
<RouterLink to="/" class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-xl shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
☁️
@@ -107,7 +107,7 @@ onMounted(() => {
</div>
</RouterLink>
<nav class="hidden md:flex items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
<nav class="hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)] md:flex">
<RouterLink
to="/"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
@@ -131,13 +131,13 @@ onMounted(() => {
</RouterLink>
</nav>
<NSpace align="center" :size="12">
<NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
<RouterLink
v-if="authStore.isLoggedIn"
to="/upload"
class="no-underline"
>
<NButton type="primary" strong>
<NButton type="primary" strong size="small" class="md:!h-10 md:!px-4 md:!text-sm">
上传
</NButton>
</RouterLink>
@@ -147,12 +147,14 @@ onMounted(() => {
to="/profile"
class="no-underline"
>
<NTag type="success" bordered>
<NTag type="success" bordered size="small" class="max-w-[7rem] truncate md:max-w-none">
{{ authStore.profile?.username }}
</NTag>
</RouterLink>
<NButton
quaternary
size="small"
class="md:!h-10 md:!px-4 md:!text-sm"
@click="authStore.logout()"
>
登出
@@ -164,17 +166,49 @@ onMounted(() => {
to="/login"
class="no-underline"
>
<NButton quaternary>登录</NButton>
<NButton quaternary size="small" class="md:!h-10 md:!px-4 md:!text-sm">登录</NButton>
</RouterLink>
<RouterLink
to="/register"
class="no-underline"
>
<NButton type="primary" strong>注册</NButton>
<NButton type="primary" strong size="small" class="md:!h-10 md:!px-4 md:!text-sm">注册</NButton>
</RouterLink>
</template>
</NSpace>
</div>
<nav class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden">
<RouterLink
to="/"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'map' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
>
地图
</RouterLink>
<RouterLink
to="/encyclopedia"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
>
图鉴
</RouterLink>
<RouterLink
to="/gallery"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'gallery' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
>
画廊
</RouterLink>
<RouterLink
v-if="authStore.isLoggedIn"
to="/profile"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'profile' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
>
主页
</RouterLink>
</nav>
</div>
</header>