feat: add fallback pages and refine page interactions

This commit is contained in:
2026-05-22 00:09:00 +08:00
parent 78b1c952e7
commit f35baf4a67
9 changed files with 175 additions and 16 deletions
+58
View File
@@ -18,6 +18,8 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@vicons/tabler": "^0.13.0",
"@vicons/utils": "^0.1.4",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
@@ -971,6 +973,26 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@vicons/tabler": {
"version": "0.13.0",
"resolved": "https://registry.npmmirror.com/@vicons/tabler/-/tabler-0.13.0.tgz",
"integrity": "sha512-AykuhiqjszkIoAL/7knIFm6RDOBS1ZmQdJfQ+RNLEah0fVsxykUFCfMBSNZh8lOzC85EtdD1k5g/sv5GYk0Ohg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vicons/utils": {
"version": "0.1.4",
"resolved": "https://registry.npmmirror.com/@vicons/utils/-/utils-0.1.4.tgz",
"integrity": "sha512-OHI19qVNN6i+uPQ+Y3f2s0dUxwsYnOCcKBW7XOU4yXXO1aU3ZoKpblCc3+4N0qmgoJs5rWKRAaMisipqEXJwAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@xicons/utils": "^0.1.4"
},
"peerDependencies": {
"vue": "^3.0.6"
}
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.7", "version": "6.0.7",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
@@ -1212,6 +1234,42 @@
} }
} }
}, },
"node_modules/@xicons/utils": {
"version": "0.1.4",
"resolved": "https://registry.npmmirror.com/@xicons/utils/-/utils-0.1.4.tgz",
"integrity": "sha512-uXxKDLz9abr80yJC05XSTq6wlyFcdW+N/1IYJkeHjzzXVc4VQ0sEYMoMMTjAH7HQBOyOkzOB4pf5NGF72lwa8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-render": "^0.13.2"
}
},
"node_modules/@xicons/utils/node_modules/@types/node": {
"version": "14.14.45",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.14.45.tgz",
"integrity": "sha512-DssMqTV9UnnoxDWu959sDLZzfvqCF0qDNRjaWeYSui9xkFe61kKo4l1TWNTQONpuXEm+gLMRvdlzvNHBamzmEw==",
"dev": true,
"license": "MIT"
},
"node_modules/@xicons/utils/node_modules/css-render": {
"version": "0.13.9",
"resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.13.9.tgz",
"integrity": "sha512-n3C4ZH59rveBrUlAD7n0Ze9/gUMKa4dlH1C9CWKpGcIHR/xRcIVXzBGy1iw8WWq2ySmn2/ZqOpySQNAK5Pb6sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emotion/hash": "~0.8.0",
"@types/node": "~14.14.31",
"csstype": "~3.0.5"
}
},
"node_modules/@xicons/utils/node_modules/csstype": {
"version": "3.0.11",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz",
"integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
+2
View File
@@ -19,6 +19,8 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@vicons/tabler": "^0.13.0",
"@vicons/utils": "^0.1.4",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
+11 -1
View File
@@ -62,6 +62,16 @@ const router = createRouter({
component: () => import('@/views/admin/AdminView.vue'), component: () => import('@/views/admin/AdminView.vue'),
meta: { requiresAuth: true, requiresAdmin: true }, meta: { requiresAuth: true, requiresAdmin: true },
}, },
{
path: '/403',
name: 'forbidden',
component: () => import('@/views/system/ForbiddenView.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/system/NotFoundView.vue'),
},
], ],
scrollBehavior() { scrollBehavior() {
return { top: 0 } return { top: 0 }
@@ -74,7 +84,7 @@ router.beforeEach((to, _from, next) => {
if (to.meta.requiresAuth && !authStore.isLoggedIn) { if (to.meta.requiresAuth && !authStore.isLoggedIn) {
next({ name: 'login', query: { redirect: to.fullPath } }) next({ name: 'login', query: { redirect: to.fullPath } })
} else if (to.meta.requiresAdmin && !authStore.isAdmin) { } else if (to.meta.requiresAdmin && !authStore.isAdmin) {
next({ name: 'map' }) next({ name: 'forbidden' })
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) { } else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
next({ name: 'map' }) next({ name: 'map' })
} else { } else {
+12 -5
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { NAlert, NCard, NEmpty, NProgress, NSkeleton } from 'naive-ui' import { NAlert, NCard, NEmpty, NIcon, NProgress, NSkeleton } from 'naive-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia' import { useEncyclopediaStore } from '@/stores/encyclopedia'
import { Lock } from '@vicons/tabler'
import type { CloudType } from '@/types/database' import type { CloudType } from '@/types/database'
const router = useRouter() const router = useRouter()
@@ -66,7 +67,7 @@ onMounted(async () => {
<div class="relative"> <div class="relative">
<div class="bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] border-b border-sky-100"> <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="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 class="grid gap-6 lg:grid-cols-[1.4fr_0.9fr] lg:items-start">
<div> <div>
<p class="text-sm font-medium tracking-[0.24em] text-sky-700 uppercase">Cloud Encyclopedia</p> <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> <h1 class="mt-3 text-4xl font-bold text-slate-900">云朵图鉴</h1>
@@ -91,8 +92,7 @@ onMounted(async () => {
type="line" type="line"
:show-indicator="false" :show-indicator="false"
:percentage="authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0" :percentage="authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0"
color="linear-gradient(90deg,#0ea5e9 0%,#f59e0b 100%)" color="{stops:['#0ea5e9','#f59e0b']}"
rail-color="#dbe4ee"
:height="12" :height="12"
/> />
@@ -161,7 +161,14 @@ onMounted(async () => {
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent"></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 v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/22 backdrop-blur-[2px]">
<div class="border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">🔒 尚未解锁</div> <div class="border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">
<span style="display: inline-flex; align-items: center; gap: 4px;">
<NIcon size="16" style="display: inline-flex;">
<Lock />
</NIcon>
<span>尚未解锁</span>
</span>
</div>
</div> </div>
<div class="absolute left-4 top-4"> <div class="absolute left-4 top-4">
+21 -4
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { NAlert, NButton, NEmpty, NSkeleton, NTag } from 'naive-ui' import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag } from 'naive-ui'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue' import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useCloudsStore } from '@/stores/clouds' import { useCloudsStore } from '@/stores/clouds'
import { Clock, User ,Location} from '@vicons/tabler'
import type { CloudType } from '@/types/database' import type { CloudType } from '@/types/database'
interface GalleryCloud { interface GalleryCloud {
@@ -260,9 +262,24 @@ onUnmounted(() => {
{{ rarityMeta[cloud.cloudTypeRarity].label }} {{ rarityMeta[cloud.cloudTypeRarity].label }}
</NTag> </NTag>
</div> </div>
<p class="mt-2 truncate text-xs text-white/82">📷 {{ cloud.username }}</p> <p class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82">
<p class="mt-1 truncate text-xs text-white/82">🕐 {{ formatUploadTime(cloud) }}</p> <NIcon size="14">
<p class="mt-1 truncate text-xs text-white/68">{{ cloud.location_name || '未填写位置' }}</p> <User />
</NIcon>
<span class="truncate">{{ cloud.username }}</span>
</p>
<p class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82">
<NIcon size="14">
<Clock />
</NIcon>
<span class="truncate">{{ formatUploadTime(cloud) }}</span>
</p>
<p class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82">
<NIcon size="14">
<Location />
</NIcon>
<span class="truncate">{{ cloud.location_name || '未填写位置' }}</span>
</p>
</div> </div>
</div> </div>
</button> </button>
+15 -2
View File
@@ -3,6 +3,8 @@ import { ref, onMounted, onUnmounted } from 'vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue' import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { loadAMap } from '@/lib/amap' import { loadAMap } from '@/lib/amap'
import { NIcon } from 'naive-ui'
import { Refresh,Map,Satellite} from '@vicons/tabler'
interface CloudMarkerData { interface CloudMarkerData {
id: string id: string
@@ -292,8 +294,19 @@ onUnmounted(() => {
<div ref="mapEl" class="w-full h-full"></div> <div ref="mapEl" class="w-full h-full"></div>
<div class="absolute bottom-6 right-4 flex flex-col gap-2 z-10"> <div class="absolute bottom-6 right-4 flex flex-col gap-2 z-10">
<button @click="refresh" class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50" title="刷新"><span class="text-lg">🔄</span></button> <button @click="refresh" class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50" title="刷新">
<button @click="toggleSat" class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'"><span class="text-lg">{{ satelliteOn ? '🗺️' : '🛰️' }}</span></button> <span class="text-lg">
<NIcon>
<Refresh/>
</NIcon>
</span>
</button>
<button @click="toggleSat" class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'">
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
<Map v-if="satelliteOn" />
<Satellite v-else />
</NIcon>
</button>
</div> </div>
<ImageDetailModal <ImageDetailModal
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import { RouterLink } from 'vue-router'
</script>
<template>
<div class="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-10">
<div class="w-full max-w-2xl border border-slate-200 bg-white px-8 py-14 text-center shadow-sm">
<div class="text-7xl leading-none">🖐</div>
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-rose-600">403 Forbidden</p>
<h1 class="mt-3 text-4xl font-bold text-slate-900">这里不让进</h1>
<p class="mx-auto mt-4 max-w-xl text-sm leading-7 text-slate-600">
你当前没有访问这个页面的权限通常是因为这个区域只对管理员开放
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<RouterLink to="/">
<NButton type="primary" secondary strong>返回地图</NButton>
</RouterLink>
<RouterLink to="/profile">
<NButton secondary strong>前往个人主页</NButton>
</RouterLink>
</div>
</div>
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import { RouterLink } from 'vue-router'
</script>
<template>
<div class="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-10">
<div class="w-full max-w-2xl border border-slate-200 bg-white px-8 py-14 text-center shadow-sm">
<div class="text-7xl leading-none">🤔</div>
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-amber-600">404 Not Found</p>
<h1 class="mt-3 text-4xl font-bold text-slate-900">我也没想明白</h1>
<p class="mx-auto mt-4 max-w-xl text-sm leading-7 text-slate-600">
这个页面不存在或者它已经被移动到了别的地方换个入口再试一次
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<RouterLink to="/">
<NButton type="primary" secondary strong>返回地图</NButton>
</RouterLink>
<RouterLink to="/gallery">
<NButton secondary strong>去画廊看看</NButton>
</RouterLink>
</div>
</div>
</div>
</template>
+4 -4
View File
@@ -447,7 +447,7 @@ onUnmounted(() => {
<input <input
v-model="activeItem.customCloudType" v-model="activeItem.customCloudType"
type="text" type="text"
placeholder="输入云型名称" placeholder="输入云的类型"
@input="activeItem.errors = {}" @input="activeItem.errors = {}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/> />
@@ -517,7 +517,7 @@ onUnmounted(() => {
</button> </button>
</div> </div>
</div> </div>
<p class="text-xs text-gray-400 mt-1">选填可手动输入或点击右侧小地图图标选</p> <p class="text-xs text-gray-400 mt-1">选填可手动输入或点击右侧小地图图标选</p>
</div> </div>
<!-- 位置名称 --> <!-- 位置名称 -->
@@ -545,7 +545,7 @@ onUnmounted(() => {
<!-- 隐身模式 --> <!-- 隐身模式 -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" /> <input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" />
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式地图显示位置</label> <label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式不在地图显示</label>
</div> </div>
</div> </div>
</NCard> </NCard>
@@ -553,7 +553,7 @@ onUnmounted(() => {
</div> </div>
<div class="mt-6 flex items-center justify-between"> <div class="mt-6 flex items-center justify-between">
<p class="text-sm text-gray-500"> {{ items.length }} 张图片类别和拍摄时间为必填项</p> <p class="text-sm text-gray-500"> {{ items.length }} 张图片</p>
<div class="flex gap-3"> <div class="flex gap-3">
<NButton secondary strong @click="clearAll()" :disabled="uploading">清空</NButton> <NButton secondary strong @click="clearAll()" :disabled="uploading">清空</NButton>
<NButton type="primary" secondary strong @click="handleSubmit" :disabled="uploading"> <NButton type="primary" secondary strong @click="handleSubmit" :disabled="uploading">