Author SHA1 Message Date
Mplan 93a096a116 fix(map): make zoom controls continuously responsive 2026-07-25 03:21:20 +08:00
Mplan ad0b6f1630 fix(admin): align cards and empty-state actions
Apply consistent hard shadows across dashboard panels, hide unavailable queue and selection actions, and present the image-management shortcut as a standard panel button.
2026-07-25 02:54:52 +08:00
Mplan 55c90e8c60 style(ui): unify sky-atlas visual system
Centralize interactive component styles, adopt the fresh eucalyptus palette, and keep map controls square with hard shadows. Update project guidance and the OpenCloud style skill to match the implementation.
2026-07-25 02:41:27 +08:00
20 changed files with 324 additions and 543 deletions
-2
View File
@@ -58,8 +58,6 @@ 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,55 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NCard } from 'naive-ui'
import { NCard, NProgress } from 'naive-ui'
const props = defineProps<{
unlockedCount: number
totalCount: number
percent: number
isLoggedIn: boolean
}>()
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))
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))
</script>
<template>
<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">
<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">
<div>
<p class="oc-stat-label">Collection Status</p>
<p class="mt-1 text-base font-semibold text-slate-900">图鉴收集进度</p>
<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 }}%
</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>
<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>
<NProgress
class="mt-5"
type="line"
:show-indicator="false"
:percentage="isLoggedIn ? percent : 0"
:color="{ stops: ['#0ea5e9', currentColor] }"
:height="12"
rail-color="#e2e8f0"
/>
<p class="mt-4 border-t border-slate-200 pt-3 text-sm text-slate-500">
<p class="mt-4 text-sm text-slate-500">
<template v-if="isLoggedIn">
已收录 {{ displayedUnlockedCount }} 种云属,还差 {{ remainingCount }} 种。
已解锁 {{ unlockedCount }} 枚徽章,还差 {{ Math.max(totalCount - unlockedCount, 0) }} 枚。
</template>
<template v-else>
登录后可同步你的个人图鉴进度。
@@ -57,52 +63,3 @@ const remainingCount = computed(() => Math.max(props.totalCount - displayedUnloc
</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>
-3
View File
@@ -49,8 +49,6 @@ function placeMarker(longitude: number, latitude: number) {
mapPickerMarker = new mapPickerAMap.Marker({
position: [longitude, latitude],
title: '拍摄位置',
content: '<div aria-hidden="true" class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
offset: new mapPickerAMap.Pixel(-8, -8),
} as AMap.MarkerOptions)
mapPickerMarker.setMap(mapPickerInstance)
} else {
@@ -87,7 +85,6 @@ async function initializeMap() {
try {
mapPickerAMap = await loadAMap()
mapPickerAMap.getConfig().appname = 'amap-jsapi-skill'
const hasCoordinates =
typeof props.latitude === 'number' &&
+10 -4
View File
@@ -207,7 +207,7 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="oc-icon-button"
class="quick-upload-close 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 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 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>
</div>
@@ -394,3 +394,9 @@ onBeforeUnmount(() => {
/>
</Teleport>
</template>
<style scoped>
.quick-upload-close.quick-upload-close {
border-radius: 9999px !important;
}
</style>
+17 -42
View File
@@ -364,51 +364,26 @@ 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>
<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>
<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>
</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>
+22 -66
View File
@@ -22,11 +22,15 @@ const props = withDefaults(defineProps<{
cells: ContributionHeatmapCell[]
totalCount: number
activeDays: number
currentStreak: number
bestStreak: number
activeDate?: string | null
title?: string
subtitle?: string
}>(), {
activeDate: null,
title: '上传活跃度',
subtitle: '按上传日期统计',
})
const emit = defineEmits<{
@@ -150,38 +154,31 @@ const visibleCells = computed(() => {
return cells
})
const contributionQuartiles = computed(() => {
const counts = visibleCells.value
.filter(cell => cell.inRange && cell.count > 0)
.map(cell => cell.count)
.sort((a, b) => a - b)
if (!counts.length) return [0, 0, 0] as const
const percentile = (value: number) => {
const index = Math.ceil(counts.length * value) - 1
return counts[Math.max(0, index)] || 0
}
return [percentile(0.25), percentile(0.5), percentile(0.75)] as const
const maxCount = computed(() => {
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0)
})
function getLevel(count: number) {
if (count <= 0) return 0
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
const max = Math.max(maxCount.value, 1)
const ratio = count / max
if (count <= firstQuartile) return 1
if (count <= secondQuartile) return 2
if (count <= thirdQuartile) return 3
return 4
if (ratio >= 0.75 || count >= 8) return 4
if (ratio >= 0.5 || count >= 5) return 3
if (ratio >= 0.25 || count >= 3) return 2
return 1
}
function getCellClass(cell: { count: number; inRange: boolean }) {
if (!cell.inRange) return 'bg-transparent border-transparent'
const level = getLevel(cell.count)
return `oc-heatmap-cell--level-${level}`
if (level === 0) return 'bg-zinc-100 border-zinc-200'
if (level === 1) return 'bg-[#e0f2fe] border-[#bae6fd]'
if (level === 2) return 'bg-[#7dd3fc] border-[#38bdf8]'
if (level === 3) return 'bg-[#0ea5e9] border-[#0284c7]'
return 'bg-[#0f3d63] border-[#0b2f4d]'
}
const weeks = computed(() => {
@@ -399,53 +396,12 @@ function formatMonthTooltip(monthKey: string) {
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>少</span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
<span class="h-3 w-3 border border-slate-200 bg-slate-100"></span>
<span class="h-3 w-3 border border-sky-200 bg-sky-100"></span>
<span class="h-3 w-3 border border-sky-400 bg-sky-300"></span>
<span class="h-3 w-3 border border-teal-600 bg-teal-500"></span>
<span class="h-3 w-3 border border-slate-900 bg-slate-900"></span>
<span>多</span>
</div>
</div>
</template>
<style scoped>
.oc-heatmap-cell,
.oc-heatmap-legend-cell {
transition:
background-color var(--oc-duration-fast) var(--oc-ease-standard),
border-color var(--oc-duration-fast) var(--oc-ease-standard),
transform var(--oc-duration-fast) var(--oc-ease-standard);
}
.oc-heatmap-legend-cell {
width: 0.75rem;
height: 0.75rem;
border-width: 1px;
}
.oc-heatmap-cell--level-0 {
background: var(--oc-heatmap-empty);
border-color: var(--oc-heatmap-empty-border);
}
.oc-heatmap-cell--level-1 {
background: var(--oc-heatmap-level-1);
border-color: var(--oc-heatmap-level-1-border);
}
.oc-heatmap-cell--level-2 {
background: var(--oc-heatmap-level-2);
border-color: var(--oc-heatmap-level-2-border);
}
.oc-heatmap-cell--level-3 {
background: var(--oc-heatmap-level-3);
border-color: var(--oc-heatmap-level-3-border);
}
.oc-heatmap-cell--level-4 {
background: var(--oc-heatmap-level-4);
border-color: var(--oc-heatmap-level-4-border);
}
</style>
+3 -18
View File
@@ -23,14 +23,7 @@ export const useAuthStore = defineStore('auth', () => {
function applyAuth(auth: AuthResponse) {
setAccessToken(auth.access_token)
user.value = auth.user
profile.value = {
id: auth.user.id,
username: auth.user.username,
avatar_url: auth.user.avatar_url,
role: auth.user.role,
is_disabled: auth.user.is_disabled,
created_at: auth.user.created_at,
}
profile.value = auth.profile
}
function clearAuth() {
@@ -65,19 +58,11 @@ export const useAuthStore = defineStore('auth', () => {
}
async function updateUsername(username: string) {
const updatedUser = await apiRequest<AuthUser>('/profiles/me', {
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
method: 'PATCH',
body: { username },
})
user.value = updatedUser
profile.value = {
id: updatedUser.id,
username: updatedUser.username,
avatar_url: updatedUser.avatar_url,
role: updatedUser.role,
is_disabled: updatedUser.is_disabled,
created_at: updatedUser.created_at,
}
profile.value = updatedProfile
}
async function updatePassword(password: string) {
+2 -20
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api'
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
export interface ProfileCloudItem {
@@ -21,8 +21,6 @@ export interface ProfileCloudItem {
is_hidden: boolean
cloudTypeName: string
cloudTypeRarity: CloudType['rarity']
favoriteCount: number
isFavorited: boolean
}
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
@@ -42,8 +40,6 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem {
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
favoriteCount: row.favorite_count,
isFavorited: row.is_favorited,
}
}
@@ -59,7 +55,6 @@ export const useProfileStore = defineStore('profile-page', () => {
const authStore = useAuthStore()
const profilesById = ref<Record<string, Profile>>({})
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
const likeStatsByUserId = ref<Record<string, LikeStats>>({})
const loadingKeys = ref<Record<string, boolean>>({})
const errorByKey = ref<Record<string, string>>({})
@@ -73,10 +68,6 @@ export const useProfileStore = defineStore('profile-page', () => {
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
}
function getLikeStats(userId: string | null) {
return userId ? likeStatsByUserId.value[userId] ?? null : null
}
function getError(userId: string | null, isOwnProfile: boolean) {
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
}
@@ -108,17 +99,9 @@ export const useProfileStore = defineStore('profile-page', () => {
const profilePromise = isOwnProfile && authStore.profile?.id === userId
? Promise.resolve(authStore.profile)
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
const likeStatsPromise = isOwnProfile
? apiRequest<LikeStats>('/profiles/me/like-stats')
: Promise.resolve(null)
const [profile, cloudRows, likeStats] = await Promise.all([
profilePromise,
fetchAllClouds(userId, isOwnProfile),
likeStatsPromise,
])
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
profilesById.value[userId] = profile
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
if (likeStats) likeStatsByUserId.value[userId] = likeStats
} catch (error) {
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
cloudsByKey.value[key] = []
@@ -203,7 +186,6 @@ export const useProfileStore = defineStore('profile-page', () => {
return {
getProfile,
getClouds,
getLikeStats,
getError,
isLoaded,
isLoading,
-15
View File
@@ -40,21 +40,6 @@
@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;
}
-12
View File
@@ -53,18 +53,6 @@
--oc-color-danger: #dc2626;
--oc-color-selection: rgb(52 116 93 / 20%);
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
--oc-heatmap-empty: #f1f5f9;
--oc-heatmap-empty-border: #d9e3ee;
--oc-heatmap-level-1: #ddf2e6;
--oc-heatmap-level-1-border: #bce4cf;
--oc-heatmap-level-2: #8fceb0;
--oc-heatmap-level-2-border: #60b18d;
--oc-heatmap-level-3: #438f71;
--oc-heatmap-level-3-border: #34745d;
--oc-heatmap-level-4: #274c40;
--oc-heatmap-level-4-border: #213e35;
--oc-gradient-canvas:
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
+3 -25
View File
@@ -4,13 +4,6 @@ export interface AuthUser {
id: string
email: string
email_verified: boolean
username: string
avatar_url: string | null
role: Profile['role']
is_disabled: boolean
cloud_count: number
public_cloud_count: number
collection_count: number
created_at: string
}
@@ -19,10 +12,12 @@ export interface AuthResponse {
token_type: 'bearer'
expires_in: number
user: AuthUser
profile: Profile
}
export interface MeResponse {
user: AuthUser
profile: Profile
}
export interface PublicProfile {
@@ -59,24 +54,6 @@ export interface ApiCloud extends Cloud {
cloud_type_name: string | null
cloud_type_rarity: CloudType['rarity'] | null
username: string | null
favorite_count: number
is_favorited: boolean
}
export interface LikeStatsPhoto {
cloud_id: string
image_url: string
thumbnail_url: string
favorite_count: number
captured_at: string | null
created_at: string
}
export interface LikeStats {
user_id: string
username: string
total_likes: number
photos: LikeStatsPhoto[]
}
export interface ApiCollection extends UserCollection {
@@ -112,4 +89,5 @@ export interface AdminStats {
export interface AdminUserItem {
user: AuthUser
profile: Profile
}
+3 -3
View File
@@ -234,7 +234,7 @@ async function fetchStats() {
async function fetchUsers() {
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
users.value = result.items.map(item => item.user)
users.value = result.items.map(item => item.profile)
}
async function fetchImages() {
@@ -435,7 +435,7 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
method: 'PATCH',
body: { role },
})
users.value = users.value.map(item => item.id === user.id ? result.user : item)
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success('用户角色已更新')
} catch (error) {
const text = getErrorMessage(error, '用户角色更新失败')
@@ -461,7 +461,7 @@ async function toggleUserDisabled(user: Profile) {
method: 'PATCH',
body: { is_disabled: nextDisabled },
})
users.value = users.value.map(item => item.id === user.id ? result.user : item)
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) {
const text = getErrorMessage(error, '用户状态更新失败')
+2 -7
View File
@@ -139,13 +139,8 @@ onUnmounted(() => {
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'email',
inputmode: 'email',
name: 'email',
required: true,
}"
required
autocomplete="email"
placeholder="your@email.com"
/>
</NFormItem>
+4 -12
View File
@@ -79,13 +79,8 @@ async function handleLogin() {
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'username',
inputmode: 'email',
name: 'username',
required: true,
}"
required
autocomplete="email"
placeholder="your@email.com"
/>
</NFormItem>
@@ -94,12 +89,9 @@ async function handleLogin() {
<NInput
v-model:value="password"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'current-password',
name: 'current-password',
required: true,
}"
autocomplete="current-password"
placeholder="输入密码"
/>
</NFormItem>
+8 -22
View File
@@ -105,11 +105,8 @@ async function handleRegister() {
<NInput
v-model:value="username"
type="text"
:input-props="{
autocomplete: 'username',
name: 'username',
required: true,
}"
required
autocomplete="username"
placeholder="你的昵称"
maxlength="20"
/>
@@ -118,13 +115,8 @@ async function handleRegister() {
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'email',
inputmode: 'email',
name: 'email',
required: true,
}"
required
autocomplete="email"
placeholder="your@email.com"
/>
</NFormItem>
@@ -133,12 +125,9 @@ async function handleRegister() {
<NInput
v-model:value="password"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
autocomplete="new-password"
placeholder="至少8位"
/>
</NFormItem>
@@ -147,12 +136,9 @@ async function handleRegister() {
<NInput
v-model:value="confirmPassword"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
autocomplete="new-password"
placeholder="再次输入密码"
/>
</NFormItem>
+4 -18
View File
@@ -142,24 +142,13 @@ onUnmounted(() => {
</div>
<NForm @submit.prevent="handleResetPassword">
<input
type="text"
name="username"
autocomplete="username"
class="sr-only"
tabindex="-1"
aria-label="账户用户名"
/>
<NFormItem label="新密码">
<NInput
v-model:value="password"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
autocomplete="new-password"
placeholder="至少 8 位"
/>
</NFormItem>
@@ -168,12 +157,9 @@ onUnmounted(() => {
<NInput
v-model:value="confirmPassword"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
autocomplete="new-password"
placeholder="再次输入新密码"
/>
</NFormItem>
+46 -72
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { NAlert, NEmpty, NIcon, NSkeleton } from 'naive-ui'
import { NAlert, NCard, 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="oc-page-hero__split">
<div class="min-w-0">
<div class="grid gap-6 lg:grid-cols-[1.4fr_0.9fr] lg:items-start">
<div>
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
<h1 class="oc-page-title">云朵图鉴</h1>
<p class="oc-page-description">
@@ -76,6 +76,7 @@ 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>
@@ -94,75 +95,81 @@ onMounted(async () => {
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
</NAlert>
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<div v-for="n in 6" :key="n" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<NSkeleton class="h-44 w-full border-b border-slate-200" />
<div class="p-5">
<NSkeleton class="h-3 w-1/3" />
<NSkeleton class="mt-3 h-6 w-2/5" />
<NSkeleton class="mt-2 h-3 w-1/2" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
<NSkeleton class="mt-5 h-10 w-full" />
</div>
</div>
<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>
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<div v-else-if="encyclopediaStore.cloudTypes.length" 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="encyclopedia-type-card oc-interactive-card oc-panel-card flex h-full flex-col overflow-hidden border bg-white text-left"
:class="isUnlocked(cloudType.id) ? 'border-sky-200' : 'border-slate-200'"
class="oc-interactive-card 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'"
>
<div class="relative h-44 shrink-0 overflow-hidden border-b border-slate-200 bg-sky-50">
<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?.thumbnail_url || getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
:alt="cloudType.name"
class="encyclopedia-type-card__image h-full w-full object-cover"
:class="isUnlocked(cloudType.id) ? '' : 'grayscale contrast-75'"
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-200 to-slate-50'"
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-300 to-slate-100'"
>
<span class="font-mono text-5xl font-bold text-slate-500/75">{{ cloudType.name.slice(0, 1) }}</span>
<span class="text-6xl font-bold text-slate-800/85">{{ cloudType.name.slice(0, 1) }}</span>
</div>
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/12">
<span class="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 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-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em]" :class="rarityMeta[cloudType.rarity].chip">
<span class="inline-flex 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="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">
<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 gap-3 border-t border-slate-200 pt-4 text-xs">
<div class="mt-5 flex items-center justify-between text-sm">
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
<span class="font-semibold uppercase tracking-[0.12em] text-sky-800">已收录</span>
<span class="text-right text-slate-500">首次记录 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
<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-semibold uppercase tracking-[0.12em] text-slate-600">等待发现</span>
<span class="text-right text-slate-400">拍到后点亮</span>
<span class="font-medium text-slate-700">等待发现</span>
<span class="text-slate-400">拍到后点亮</span>
</template>
</div>
</div>
@@ -182,36 +189,3 @@ 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>
+2 -2
View File
@@ -342,7 +342,7 @@ onUnmounted(() => {
<template>
<div>
<section class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<div class="oc-container oc-container--wide oc-page-hero__inner">
<p class="oc-page-eyebrow">Community Gallery</p>
<h1 class="oc-page-title">云图画廊</h1>
<p class="oc-page-description">
@@ -351,7 +351,7 @@ onUnmounted(() => {
</div>
</section>
<div class="oc-container oc-container--content oc-page-content">
<div class="oc-container oc-container--wide oc-page-content">
<section>
<div class="mb-4 border border-slate-200 bg-white p-3 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
<div class="flex items-center gap-3">
+8 -26
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
import { NAlert, NButton, NCard, NInput, useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -79,8 +79,8 @@ async function savePassword() {
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] py-10">
<div class="oc-container oc-container--content">
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] px-4 py-10">
<div class="mx-auto max-w-4xl">
<RouterLink to="/profile">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回个人主页</NButton>
</RouterLink>
@@ -148,37 +148,19 @@ async function savePassword() {
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
</p>
</div>
<NForm class="grid gap-3" @submit.prevent="savePassword">
<input
:value="authStore.user?.email || ''"
type="text"
name="username"
autocomplete="username"
class="sr-only"
tabindex="-1"
aria-label="账户邮箱"
readonly
/>
<div class="grid gap-3">
<NInput
v-model:value="newPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
autocomplete="new-password"
placeholder="新密码"
/>
<NInput
v-model:value="confirmNewPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
autocomplete="new-password"
placeholder="确认新密码"
/>
<div class="flex justify-end">
@@ -186,14 +168,14 @@ async function savePassword() {
secondary
strong
type="default"
attr-type="submit"
class="oc-panel-button oc-panel-button--teal"
:loading="passwordSaving"
@click="savePassword"
>
保存密码
</NButton>
</div>
</NForm>
</div>
</div>
</NCard>
</div>
+150 -91
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
import { Settings } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
@@ -9,6 +10,7 @@ import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
import type { CloudType, Profile } from '@/types/database'
@@ -21,6 +23,7 @@ interface TimelineGroup {
const route = useRoute()
const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
const encyclopediaStore = useEncyclopediaStore()
const profileStore = useProfileStore()
const message = useMessage()
@@ -62,7 +65,6 @@ const errorMsg = computed(() => {
})
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
const receivedLikes = computed(() => profileStore.getLikeStats(viewedUserId.value)?.total_likes || 0)
const pageTitle = computed(() => {
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
@@ -81,6 +83,10 @@ const approvedShots = computed(() => {
return clouds.value.filter(item => item.status === 'approved').length
})
const pendingShots = computed(() => {
return clouds.value.filter(item => item.status === 'pending').length
})
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
const cloudActionOptions = computed(() => {
@@ -178,6 +184,44 @@ const activeUploadDays = computed(() => {
return contributionCells.value.filter(cell => cell.count > 0).length
})
const currentUploadStreak = computed(() => {
let streak = 0
const cursor = startOfDay(new Date())
while (true) {
const key = toLocalDayKey(cursor)
if ((uploadDayCounts.value.get(key) || 0) <= 0) break
streak += 1
cursor.setDate(cursor.getDate() - 1)
}
return streak
})
const longestUploadStreak = computed(() => {
let best = 0
let current = 0
let previousDate: Date | null = null
for (const cell of contributionCells.value) {
if (cell.count <= 0) continue
const currentDate = startOfDay(new Date(cell.date))
if (!previousDate) {
current = 1
} else {
const diffDays = Math.round((currentDate.getTime() - previousDate.getTime()) / 86400000)
current = diffDays === 1 ? current + 1 : 1
}
if (current > best) best = current
previousDate = currentDate
}
return best
})
function toDayKey(iso: string) {
const date = new Date(iso)
return toLocalDayKey(date)
@@ -189,6 +233,10 @@ function toLocalDayKey(date: Date) {
return `${date.getFullYear()}-${month}-${day}`
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function formatDate(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleDateString('zh-CN', {
@@ -400,6 +448,8 @@ async function loadProfilePage(force = false) {
if (isOwnProfile.value) {
await cloudsStore.fetchCloudTypes()
await encyclopediaStore.fetchCloudTypes(force)
await encyclopediaStore.fetchMyCollection(force)
}
}
@@ -425,98 +475,77 @@ watch(selectedUploadDate, async newValue => {
<div class="relative">
<div class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<div
v-if="loading"
class="oc-page-hero__split"
>
<NCard class="oc-panel-card h-full border border-slate-200 shadow-sm" :bordered="false">
<div v-if="loading" class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
<NCard class="oc-panel-card 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 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" />
</NCard>
</div>
<NCard v-else class="oc-panel-card-soft h-full border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<NSkeleton class="h-5 w-1/3" />
<NSkeleton class="mt-4 h-8 w-1/2" />
<NSkeleton class="mt-5 h-3 w-full" />
</NCard>
</div>
<div
v-else
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">
<div
class="flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]"
>
<img
v-if="profileData?.avatar_url"
:src="profileData.avatar_url"
:alt="profileData.username"
class="h-full w-full object-cover"
/>
<template v-else>
{{ getProfileInitial(profileData) }}
</template>
</div>
<div v-else class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-start">
<div class="flex gap-5">
<div
class="flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]"
>
<img
v-if="profileData?.avatar_url"
:src="profileData.avatar_url"
:alt="profileData.username"
class="h-full w-full object-cover"
/>
<template v-else>
{{ getProfileInitial(profileData) }}
</template>
</div>
<div class="min-w-0">
<p class="oc-page-eyebrow">Sky Journal</p>
<h1 class="oc-page-title">
{{ pageTitle }}
</h1>
<div class="mt-3 flex flex-wrap items-center gap-3">
<p class="text-lg font-medium text-slate-700">
@{{ profileData?.username || '未知用户' }}
</p>
<RouterLink v-if="isOwnProfile" to="/profile/settings">
<button
type="button"
class="oc-icon-button oc-icon-button--small"
title="个人资料设置"
aria-label="个人资料设置"
>
<NIcon size="17">
<Settings />
</NIcon>
</button>
</RouterLink>
</div>
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
</div>
<div class="min-w-0">
<p class="oc-page-eyebrow">Sky Journal</p>
<h1 class="oc-page-title">
{{ pageTitle }}
</h1>
<div class="mt-3 flex flex-wrap items-center gap-3">
<p class="text-lg font-medium text-slate-700">
@{{ profileData?.username || '未知用户' }}
</p>
<RouterLink v-if="isOwnProfile" to="/profile/settings">
<button
type="button"
class="oc-icon-button oc-icon-button--small"
title="个人资料设置"
aria-label="个人资料设置"
>
<NIcon size="17">
<Settings />
</NIcon>
</button>
</RouterLink>
</div>
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
{{ profileSubtitle }}
</p> -->
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
<!-- <NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
<NTag v-else :bordered="false">公开展示</NTag> -->
</div>
</div>
</NCard>
<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>
</NCard>
<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="mt-2 truncate text-xl font-bold text-slate-900">{{ mostCommonCloudType.name }}</p>
</NCard>
<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">{{ shootingDays }}</p>
</NCard>
<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">{{ receivedLikes }}</p>
</NCard>
</div>
<NCard v-if="!isOwnProfile" class="oc-panel-card-soft h-full border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<EncyclopediaProgressCard
v-if="isOwnProfile"
:unlocked-count="encyclopediaStore.unlockedCount"
:total-count="encyclopediaStore.cloudTypes.length || 10"
:percent="encyclopediaStore.unlockPercent"
:is-logged-in="true"
/>
<NCard v-else class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<p class="text-sm text-slate-500">公开贡献</p>
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
@@ -548,12 +577,50 @@ watch(selectedUploadDate, async newValue => {
</NAlert>
<template v-else>
<section ref="timelineSectionRef">
<div v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<NCard v-for="n in 4" :key="n">
<NSkeleton class="h-4 w-1/3" />
<NSkeleton class="mt-4 h-8 w-2/3" />
</NCard>
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<p class="text-sm text-slate-500">总拍摄数</p>
<p class="mt-2 text-3xl font-bold text-slate-900">{{ totalShots }}</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<p class="text-sm text-slate-500">最常拍云型</p>
<p class="oc-stat-value">{{ mostCommonCloudType.name }}</p>
<p class="mt-2 text-sm text-slate-500" v-if="mostCommonCloudType.count">
共 {{ mostCommonCloudType.count }} 张
</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<p class="text-sm text-slate-500">拍摄天数</p>
<p class="mt-2 text-3xl font-bold text-slate-900">{{ shootingDays }}</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<p class="text-sm text-slate-500">{{ isOwnProfile ? '已通过 / 待审核' : '公开通过数' }}</p>
<p class="oc-stat-value">
<template v-if="isOwnProfile">{{ approvedShots }} / {{ pendingShots }}</template>
<template v-else>{{ approvedShots }}</template>
</p>
</NCard>
</div>
<section ref="timelineSectionRef" class="mt-8">
<ContributionHeatmap
:cells="contributionCells"
:total-count="totalShots"
:active-days="activeUploadDays"
:current-streak="currentUploadStreak"
:best-streak="longestUploadStreak"
:active-date="selectedUploadDate"
:subtitle="isOwnProfile ? '按上传日期统计' : '按公开上传日期统计'"
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
@select-date="toggleSelectedUploadDate"
/>
@@ -601,12 +668,8 @@ watch(selectedUploadDate, async newValue => {
>
清除筛选
</NButton>
<RouterLink
v-if="isOwnProfile"
to="/upload"
class="oc-panel-button oc-panel-button--sky border-0"
>
继续上传
<RouterLink v-if="isOwnProfile" to="/upload">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">继续上传</NButton>
</RouterLink>
</div>
</div>
@@ -709,12 +772,8 @@ watch(selectedUploadDate, async newValue => {
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
<template #extra>
<RouterLink
v-if="isOwnProfile && !selectedUploadDate"
to="/upload"
class="oc-panel-button oc-panel-button--sky border-0"
>
去上传第一张
<RouterLink v-if="isOwnProfile && !selectedUploadDate" to="/upload">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">去上传第一张</NButton>
</RouterLink>
<NButton v-else-if="selectedUploadDate" secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
</template>