2 Commits
7 changed files with 224 additions and 119 deletions
+2
View File
@@ -58,6 +58,8 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
容器宽度只有三个语义档位:`oc-container--narrow`、`oc-container--content`、`oc-container--wide`。不要在同类页面重复手写 `max-w-* mx-auto px-*`。
Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split`。它会在有效视口达到 `900px` 时启用接近均衡的双栏,避免浏览器缩放到 125% 后过早堆叠;需要两栏等高时同时添加 `oc-page-hero__split--stretch`。
### 表单
```html
@@ -1,61 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NCard, NProgress } from 'naive-ui'
import { NCard } from 'naive-ui'
const props = defineProps<{
unlockedCount: number
totalCount: number
percent: number
isLoggedIn: boolean
}>()
function lerpHex(a: string, b: string, t: number) {
const ah = parseInt(a.slice(1), 16)
const bh = parseInt(b.slice(1), 16)
const r = Math.round(((ah >> 16) & 0xff) + (((bh >> 16) & 0xff) - ((ah >> 16) & 0xff)) * t)
const g = Math.round(((ah >> 8) & 0xff) + (((bh >> 8) & 0xff) - ((ah >> 8) & 0xff)) * t)
const bv = Math.round((ah & 0xff) + ((bh & 0xff) - (ah & 0xff)) * t)
return `#${((r << 16) | (g << 8) | bv).toString(16).padStart(6, '0')}`
}
function progressColor(pct: number) {
const t = Math.min(1, Math.max(0, pct / 100))
if (t <= 0.5) return lerpHex('#0ea5e9', '#f59e0b', t * 2)
return lerpHex('#f59e0b', '#ef4444', (t - 0.5) * 2)
}
const progressText = computed(() => `${props.unlockedCount}/${props.totalCount}`)
const currentColor = computed(() => progressColor(props.isLoggedIn ? props.percent : 0))
const displayedUnlockedCount = computed(() => {
if (!props.isLoggedIn) return 0
return Math.min(Math.max(props.unlockedCount, 0), props.totalCount)
})
const remainingCount = computed(() => Math.max(props.totalCount - displayedUnlockedCount.value, 0))
</script>
<template>
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<div class="flex items-center justify-between">
<NCard class="oc-panel-card-soft border border-sky-100 bg-white/88 backdrop-blur" :bordered="false">
<div class="flex items-end justify-between gap-4">
<div>
<p class="text-sm text-slate-500">当前进度</p>
<p class="mt-1 text-3xl font-bold" :style="{ color: currentColor }">{{ progressText }}</p>
</div>
<div
class="flex h-16 w-16 items-center justify-center border text-xl font-semibold text-white transition-colors duration-500"
:style="{ backgroundColor: currentColor, borderColor: currentColor }"
>
{{ isLoggedIn ? percent : 0 }}%
<p class="oc-stat-label">Collection Status</p>
<p class="mt-1 text-base font-semibold text-slate-900">图鉴收集进度</p>
</div>
<p class="shrink-0 font-mono tabular-nums text-slate-500">
<span class="text-3xl font-bold text-sky-800">{{ displayedUnlockedCount }}</span>
<span class="ml-1 text-sm">/ {{ totalCount }}</span>
</p>
</div>
<NProgress
class="mt-5"
type="line"
:show-indicator="false"
:percentage="isLoggedIn ? percent : 0"
:color="{ stops: ['#0ea5e9', currentColor] }"
:height="12"
rail-color="#e2e8f0"
/>
<div
class="mt-5 grid grid-cols-5 gap-2 sm:grid-cols-10"
role="progressbar"
aria-label="云属收集进度"
aria-valuemin="0"
:aria-valuemax="totalCount"
:aria-valuenow="displayedUnlockedCount"
:aria-valuetext="`已收录 ${displayedUnlockedCount} 种,共 ${totalCount} 种`"
>
<span
v-for="index in totalCount"
:key="index"
aria-hidden="true"
class="encyclopedia-progress-cell aspect-square border border-slate-300 bg-slate-50"
:class="{ 'encyclopedia-progress-cell--active': index <= displayedUnlockedCount }"
:style="{ animationDelay: `${(index - 1) * 90}ms` }"
></span>
</div>
<p class="mt-4 text-sm text-slate-500">
<p class="mt-4 border-t border-slate-200 pt-3 text-sm text-slate-500">
<template v-if="isLoggedIn">
已解锁 {{ unlockedCount }} 枚徽章,还差 {{ Math.max(totalCount - unlockedCount, 0) }} 枚。
已收录 {{ displayedUnlockedCount }} 种云属,还差 {{ remainingCount }} 种。
</template>
<template v-else>
登录后可同步你的个人图鉴进度。
@@ -63,3 +57,52 @@ const currentColor = computed(() => progressColor(props.isLoggedIn ? props.perce
</p>
</NCard>
</template>
<style scoped>
.encyclopedia-progress-cell--active {
background: rgb(224 242 254);
border-color: rgb(125 211 252);
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
animation:
encyclopedia-cell-activate 1ms linear both,
encyclopedia-cell-pulse 220ms ease-out both;
}
@keyframes encyclopedia-cell-activate {
from {
background: rgb(248 250 252);
border-color: rgb(203 213 225);
box-shadow: none;
}
to {
background: rgb(224 242 254);
border-color: rgb(125 211 252);
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
}
}
@keyframes encyclopedia-cell-pulse {
0% {
transform: scale(1);
}
28% {
transform: scale(0.9);
}
62% {
transform: scale(1.06);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.encyclopedia-progress-cell--active {
animation: none;
}
}
</style>
+4 -10
View File
@@ -207,7 +207,7 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="quick-upload-close oc-icon-button"
class="oc-icon-button"
aria-label="关闭快捷上传"
title="关闭快捷上传"
@click="close"
@@ -251,9 +251,9 @@ onBeforeUnmount(() => {
<div class="overflow-hidden border border-slate-900 bg-slate-950">
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<button type="button" class="oc-text-button text-sm" @click="fileInput?.click()">重新选择</button>
<button type="button" class="oc-text-button oc-text-button--danger text-sm" @click="handleRemove(activeItem.id)">移除</button>
<div class="mt-3 flex items-center gap-2">
<button type="button" class="oc-panel-button oc-panel-button--neutral flex-1" @click="fileInput?.click()">重新选择</button>
<button type="button" class="oc-panel-button oc-panel-button--danger flex-1" @click="handleRemove(activeItem.id)">移除</button>
</div>
</div>
@@ -394,9 +394,3 @@ onBeforeUnmount(() => {
/>
</Teleport>
</template>
<style scoped>
.quick-upload-close.quick-upload-close {
border-radius: 9999px !important;
}
</style>
+42 -17
View File
@@ -364,26 +364,51 @@ async function handleLogout() {
>
图鉴
</RouterLink>
<RouterLink
v-if="!authStore.loading && authStore.isLoggedIn"
to="/profile"
class="oc-nav-link shrink-0"
:class="route.name === 'profile' ? activeNavClass : inactiveNavClass"
>
主页
</RouterLink>
</nav>
</div>
</header>
<button
v-if="headerHidden"
type="button"
class="absolute left-1/2 top-3 flex -translate-x-1/2 items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600"
@click="showHeader(true)"
>
<span>▼</span>
<span>展开导航</span>
</button>
<Transition name="header-reveal">
<div
v-if="headerHidden"
class="pointer-events-none absolute inset-x-0 top-3 flex justify-center"
>
<button
type="button"
class="pointer-events-auto flex items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600"
@click="showHeader(true)"
>
<span>▼</span>
<span>展开导航</span>
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.header-reveal-enter-active,
.header-reveal-leave-active {
transition:
opacity 240ms ease,
transform 240ms ease;
}
.header-reveal-enter-from,
.header-reveal-leave-to {
opacity: 0;
transform: translateY(-0.75rem);
}
@media (prefers-reduced-motion: reduce) {
.header-reveal-enter-active,
.header-reveal-leave-active {
transition-duration: 1ms;
}
.header-reveal-enter-from,
.header-reveal-leave-to {
transform: none;
}
}
</style>
+15
View File
@@ -40,6 +40,21 @@
@apply py-10;
}
.oc-page-hero__split {
@apply grid gap-6;
}
@media (min-width: 900px) {
.oc-page-hero__split {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr);
align-items: start;
}
.oc-page-hero__split--stretch {
align-items: stretch;
}
}
.oc-page-content {
@apply py-8;
}
+74 -48
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { NAlert, NCard, NEmpty, NIcon, NSkeleton } from 'naive-ui'
import { NAlert, NEmpty, NIcon, NSkeleton } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
@@ -64,8 +64,8 @@ onMounted(async () => {
<div class="relative">
<div class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<div class="grid gap-6 lg:grid-cols-[1.4fr_0.9fr] lg:items-start">
<div>
<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">
@@ -76,7 +76,6 @@ onMounted(async () => {
<EncyclopediaProgressCard
:unlocked-count="authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0"
:total-count="encyclopediaStore.cloudTypes.length || 10"
:percent="encyclopediaStore.unlockPercent"
:is-logged-in="authStore.isLoggedIn"
/>
</div>
@@ -95,81 +94,75 @@ onMounted(async () => {
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
</NAlert>
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<NCard v-for="n in 6" :key="n" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<NSkeleton class="h-44 w-full" />
<NSkeleton class="mt-5 h-5 w-2/5" />
<NSkeleton class="mt-3 h-4 w-1/3" />
<NSkeleton class="mt-6 h-4 w-full" :repeat="2" />
</NCard>
<div 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>
</div>
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<button
v-for="cloudType in encyclopediaStore.cloudTypes"
:key="cloudType.id"
type="button"
@click="openCard(cloudType)"
class="oc-interactive-card group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
:class="isUnlocked(cloudType.id) ? 'border-amber-300 shadow-amber-100/60' : 'border-slate-200 hover:border-slate-300'"
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'"
>
<div
class="relative h-44 overflow-hidden border-b"
:class="isUnlocked(cloudType.id) ? 'border-white/40' : 'border-slate-200'"
>
<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="h-full w-full object-cover transition duration-500 group-hover:scale-105"
:class="isUnlocked(cloudType.id) ? '' : 'grayscale'"
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-300 to-slate-100'"
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-200 to-slate-50'"
>
<span class="text-6xl font-bold text-slate-800/85">{{ cloudType.name.slice(0, 1) }}</span>
<span class="font-mono text-5xl font-bold text-slate-500/75">{{ 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="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 class="absolute left-4 top-4">
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[cloudType.rarity].chip">
{{ rarityMeta[cloudType.rarity].label }}
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/12">
<span class="inline-flex items-center gap-2 border border-slate-300 bg-white/92 px-3 py-2 text-xs font-semibold text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.10)] backdrop-blur">
<NIcon size="15"><Lock /></NIcon>
尚未收录
</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 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="p-5">
<p class="line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
<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 text-sm">
<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-medium text-amber-700">已收录</span>
<span class="text-slate-500">首次记录于 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
<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-medium text-slate-700">等待发现</span>
<span class="text-slate-400">拍到后点亮</span>
<span class="font-semibold uppercase tracking-[0.12em] text-slate-600">等待发现</span>
<span class="text-right text-slate-400">拍到后点亮</span>
</template>
</div>
</div>
@@ -189,3 +182,36 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.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>
+4 -4
View File
@@ -427,14 +427,14 @@ watch(selectedUploadDate, async newValue => {
<div class="oc-container oc-container--content oc-page-hero__inner">
<div
v-if="loading"
class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]"
class="oc-page-hero__split"
>
<NCard class="oc-panel-card h-full border border-slate-200 shadow-sm" :bordered="false">
<NSkeleton class="h-6 w-1/3" />
<NSkeleton class="mt-4 h-10 w-2/3" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
</NCard>
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 lg:h-full lg:grid-rows-2">
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2">
<NCard v-for="n in 4" :key="n" class="oc-panel-card-soft h-full border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<NSkeleton class="h-3 w-2/3" />
<NSkeleton class="mt-3 h-7 w-1/2" />
@@ -450,7 +450,7 @@ watch(selectedUploadDate, async newValue => {
<div
v-else
class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-stretch"
class="oc-page-hero__split oc-page-hero__split--stretch"
>
<NCard class="oc-panel-card h-full border border-slate-200 bg-white/90 shadow-sm backdrop-blur" :bordered="false">
<div class="flex gap-5">
@@ -497,7 +497,7 @@ watch(selectedUploadDate, async newValue => {
</div>
</NCard>
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 lg:h-full lg:grid-rows-2">
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2">
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<p class="oc-stat-label">总拍摄数</p>
<p class="oc-stat-value">{{ totalShots }}</p>