fix(frontend): adapt to updated profile APIs

This commit is contained in:
2026-07-28 23:23:19 +08:00
parent ccf7049ae0
commit 1a7fd11eee
8 changed files with 237 additions and 185 deletions
+66 -22
View File
@@ -22,15 +22,11 @@ const props = withDefaults(defineProps<{
cells: ContributionHeatmapCell[] cells: ContributionHeatmapCell[]
totalCount: number totalCount: number
activeDays: number activeDays: number
currentStreak: number
bestStreak: number
activeDate?: string | null activeDate?: string | null
title?: string title?: string
subtitle?: string
}>(), { }>(), {
activeDate: null, activeDate: null,
title: '上传活跃度', title: '上传活跃度',
subtitle: '按上传日期统计',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -154,31 +150,38 @@ const visibleCells = computed(() => {
return cells return cells
}) })
const maxCount = computed(() => { const contributionQuartiles = computed(() => {
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0) 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
}) })
function getLevel(count: number) { function getLevel(count: number) {
if (count <= 0) return 0 if (count <= 0) return 0
const max = Math.max(maxCount.value, 1) const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
const ratio = count / max
if (ratio >= 0.75 || count >= 8) return 4 if (count <= firstQuartile) return 1
if (ratio >= 0.5 || count >= 5) return 3 if (count <= secondQuartile) return 2
if (ratio >= 0.25 || count >= 3) return 2 if (count <= thirdQuartile) return 3
return 1 return 4
} }
function getCellClass(cell: { count: number; inRange: boolean }) { function getCellClass(cell: { count: number; inRange: boolean }) {
if (!cell.inRange) return 'bg-transparent border-transparent' if (!cell.inRange) return 'bg-transparent border-transparent'
const level = getLevel(cell.count) const level = getLevel(cell.count)
if (level === 0) return 'bg-zinc-100 border-zinc-200' return `oc-heatmap-cell--level-${level}`
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(() => { const weeks = computed(() => {
@@ -396,12 +399,53 @@ function formatMonthTooltip(monthKey: string) {
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500"> <div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>少</span> <span>少</span>
<span class="h-3 w-3 border border-slate-200 bg-slate-100"></span> <span class="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
<span class="h-3 w-3 border border-sky-200 bg-sky-100"></span> <span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
<span class="h-3 w-3 border border-sky-400 bg-sky-300"></span> <span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
<span class="h-3 w-3 border border-teal-600 bg-teal-500"></span> <span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
<span class="h-3 w-3 border border-slate-900 bg-slate-900"></span> <span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
<span>多</span> <span>多</span>
</div> </div>
</div> </div>
</template> </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>
+18 -3
View File
@@ -23,7 +23,14 @@ export const useAuthStore = defineStore('auth', () => {
function applyAuth(auth: AuthResponse) { function applyAuth(auth: AuthResponse) {
setAccessToken(auth.access_token) setAccessToken(auth.access_token)
user.value = auth.user user.value = auth.user
profile.value = auth.profile 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,
}
} }
function clearAuth() { function clearAuth() {
@@ -58,11 +65,19 @@ export const useAuthStore = defineStore('auth', () => {
} }
async function updateUsername(username: string) { async function updateUsername(username: string) {
const updatedProfile = await apiRequest<Profile>('/profiles/me', { const updatedUser = await apiRequest<AuthUser>('/profiles/me', {
method: 'PATCH', method: 'PATCH',
body: { username }, body: { username },
}) })
profile.value = updatedProfile 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,
}
} }
async function updatePassword(password: string) { async function updatePassword(password: string) {
+20 -2
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api' import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api' import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api'
import type { CloudType, Profile } from '@/types/database' import type { CloudType, Profile } from '@/types/database'
export interface ProfileCloudItem { export interface ProfileCloudItem {
@@ -21,6 +21,8 @@ export interface ProfileCloudItem {
is_hidden: boolean is_hidden: boolean
cloudTypeName: string cloudTypeName: string
cloudTypeRarity: CloudType['rarity'] cloudTypeRarity: CloudType['rarity']
favoriteCount: number
isFavorited: boolean
} }
function toProfileCloud(row: ApiCloud): ProfileCloudItem { function toProfileCloud(row: ApiCloud): ProfileCloudItem {
@@ -40,6 +42,8 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem {
is_hidden: row.is_hidden, is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知', cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common', cloudTypeRarity: row.cloud_type_rarity || 'common',
favoriteCount: row.favorite_count,
isFavorited: row.is_favorited,
} }
} }
@@ -55,6 +59,7 @@ export const useProfileStore = defineStore('profile-page', () => {
const authStore = useAuthStore() const authStore = useAuthStore()
const profilesById = ref<Record<string, Profile>>({}) const profilesById = ref<Record<string, Profile>>({})
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({}) const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
const likeStatsByUserId = ref<Record<string, LikeStats>>({})
const loadingKeys = ref<Record<string, boolean>>({}) const loadingKeys = ref<Record<string, boolean>>({})
const errorByKey = ref<Record<string, string>>({}) const errorByKey = ref<Record<string, string>>({})
@@ -68,6 +73,10 @@ export const useProfileStore = defineStore('profile-page', () => {
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : [] 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) { function getError(userId: string | null, isOwnProfile: boolean) {
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : '' return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
} }
@@ -99,9 +108,17 @@ export const useProfileStore = defineStore('profile-page', () => {
const profilePromise = isOwnProfile && authStore.profile?.id === userId const profilePromise = isOwnProfile && authStore.profile?.id === userId
? Promise.resolve(authStore.profile) ? Promise.resolve(authStore.profile)
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile) : apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)]) const likeStatsPromise = isOwnProfile
? apiRequest<LikeStats>('/profiles/me/like-stats')
: Promise.resolve(null)
const [profile, cloudRows, likeStats] = await Promise.all([
profilePromise,
fetchAllClouds(userId, isOwnProfile),
likeStatsPromise,
])
profilesById.value[userId] = profile profilesById.value[userId] = profile
cloudsByKey.value[key] = cloudRows.map(toProfileCloud) cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
if (likeStats) likeStatsByUserId.value[userId] = likeStats
} catch (error) { } catch (error) {
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败' errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
cloudsByKey.value[key] = [] cloudsByKey.value[key] = []
@@ -186,6 +203,7 @@ export const useProfileStore = defineStore('profile-page', () => {
return { return {
getProfile, getProfile,
getClouds, getClouds,
getLikeStats,
getError, getError,
isLoaded, isLoaded,
isLoading, isLoading,
+12
View File
@@ -53,6 +53,18 @@
--oc-color-danger: #dc2626; --oc-color-danger: #dc2626;
--oc-color-selection: rgb(52 116 93 / 20%); --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: --oc-gradient-canvas:
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%), radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%); linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
+25 -3
View File
@@ -4,6 +4,13 @@ export interface AuthUser {
id: string id: string
email: string email: string
email_verified: boolean 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 created_at: string
} }
@@ -12,12 +19,10 @@ export interface AuthResponse {
token_type: 'bearer' token_type: 'bearer'
expires_in: number expires_in: number
user: AuthUser user: AuthUser
profile: Profile
} }
export interface MeResponse { export interface MeResponse {
user: AuthUser user: AuthUser
profile: Profile
} }
export interface PublicProfile { export interface PublicProfile {
@@ -54,6 +59,24 @@ export interface ApiCloud extends Cloud {
cloud_type_name: string | null cloud_type_name: string | null
cloud_type_rarity: CloudType['rarity'] | null cloud_type_rarity: CloudType['rarity'] | null
username: string | 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 { export interface ApiCollection extends UserCollection {
@@ -89,5 +112,4 @@ export interface AdminStats {
export interface AdminUserItem { export interface AdminUserItem {
user: AuthUser user: AuthUser
profile: Profile
} }
+3 -3
View File
@@ -234,7 +234,7 @@ async function fetchStats() {
async function fetchUsers() { async function fetchUsers() {
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100') const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
users.value = result.items.map(item => item.profile) users.value = result.items.map(item => item.user)
} }
async function fetchImages() { async function fetchImages() {
@@ -435,7 +435,7 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
method: 'PATCH', method: 'PATCH',
body: { role }, body: { role },
}) })
users.value = users.value.map(item => item.id === user.id ? result.profile : item) users.value = users.value.map(item => item.id === user.id ? result.user : item)
message.success('用户角色已更新') message.success('用户角色已更新')
} catch (error) { } catch (error) {
const text = getErrorMessage(error, '用户角色更新失败') const text = getErrorMessage(error, '用户角色更新失败')
@@ -461,7 +461,7 @@ async function toggleUserDisabled(user: Profile) {
method: 'PATCH', method: 'PATCH',
body: { is_disabled: nextDisabled }, body: { is_disabled: nextDisabled },
}) })
users.value = users.value.map(item => item.id === user.id ? result.profile : item) users.value = users.value.map(item => item.id === user.id ? result.user : item)
message.success(nextDisabled ? '用户已禁用' : '用户已恢复') message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) { } catch (error) {
const text = getErrorMessage(error, '用户状态更新失败') const text = getErrorMessage(error, '用户状态更新失败')
+14 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { NAlert, NButton, NCard, NInput, useMessage } from 'naive-ui' import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -148,19 +148,27 @@ async function savePassword() {
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。 修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
</p> </p>
</div> </div>
<div class="grid gap-3"> <NForm class="grid gap-3" @submit.prevent="savePassword">
<NInput <NInput
v-model:value="newPassword" v-model:value="newPassword"
type="password" type="password"
show-password-on="click" show-password-on="click"
autocomplete="new-password" :input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
placeholder="新密码" placeholder="新密码"
/> />
<NInput <NInput
v-model:value="confirmNewPassword" v-model:value="confirmNewPassword"
type="password" type="password"
show-password-on="click" show-password-on="click"
autocomplete="new-password" :input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
placeholder="确认新密码" placeholder="确认新密码"
/> />
<div class="flex justify-end"> <div class="flex justify-end">
@@ -168,14 +176,14 @@ async function savePassword() {
secondary secondary
strong strong
type="default" type="default"
attr-type="submit"
class="oc-panel-button oc-panel-button--teal" class="oc-panel-button oc-panel-button--teal"
:loading="passwordSaving" :loading="passwordSaving"
@click="savePassword"
> >
保存密码 保存密码
</NButton> </NButton>
</div> </div>
</div> </NForm>
</div> </div>
</NCard> </NCard>
</div> </div>
+79 -146
View File
@@ -2,7 +2,6 @@
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui' 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 { Settings } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue' import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue' import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
@@ -10,7 +9,6 @@ import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue' import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds' import { useCloudsStore } from '@/stores/clouds'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile' import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
import type { CloudType, Profile } from '@/types/database' import type { CloudType, Profile } from '@/types/database'
@@ -23,7 +21,6 @@ interface TimelineGroup {
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const cloudsStore = useCloudsStore() const cloudsStore = useCloudsStore()
const encyclopediaStore = useEncyclopediaStore()
const profileStore = useProfileStore() const profileStore = useProfileStore()
const message = useMessage() const message = useMessage()
@@ -65,6 +62,7 @@ const errorMsg = computed(() => {
}) })
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value)) const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value)) const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
const receivedLikes = computed(() => profileStore.getLikeStats(viewedUserId.value)?.total_likes || 0)
const pageTitle = computed(() => { const pageTitle = computed(() => {
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志` return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
@@ -83,10 +81,6 @@ const approvedShots = computed(() => {
return clouds.value.filter(item => item.status === 'approved').length 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 selectedCloudCount = computed(() => selectedCloudIds.value.size)
const cloudActionOptions = computed(() => { const cloudActionOptions = computed(() => {
@@ -184,44 +178,6 @@ const activeUploadDays = computed(() => {
return contributionCells.value.filter(cell => cell.count > 0).length 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) { function toDayKey(iso: string) {
const date = new Date(iso) const date = new Date(iso)
return toLocalDayKey(date) return toLocalDayKey(date)
@@ -233,10 +189,6 @@ function toLocalDayKey(date: Date) {
return `${date.getFullYear()}-${month}-${day}` return `${date.getFullYear()}-${month}-${day}`
} }
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function formatDate(iso: string | null) { function formatDate(iso: string | null) {
if (!iso) return '未知时间' if (!iso) return '未知时间'
return new Date(iso).toLocaleDateString('zh-CN', { return new Date(iso).toLocaleDateString('zh-CN', {
@@ -448,8 +400,6 @@ async function loadProfilePage(force = false) {
if (isOwnProfile.value) { if (isOwnProfile.value) {
await cloudsStore.fetchCloudTypes() await cloudsStore.fetchCloudTypes()
await encyclopediaStore.fetchCloudTypes(force)
await encyclopediaStore.fetchMyCollection(force)
} }
} }
@@ -475,77 +425,98 @@ watch(selectedUploadDate, async newValue => {
<div class="relative"> <div class="relative">
<div class="oc-page-hero"> <div class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner"> <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]"> <div
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false"> v-if="loading"
class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]"
>
<NCard class="oc-panel-card h-full border border-slate-200 shadow-sm" :bordered="false">
<NSkeleton class="h-6 w-1/3" /> <NSkeleton class="h-6 w-1/3" />
<NSkeleton class="mt-4 h-10 w-2/3" /> <NSkeleton class="mt-4 h-10 w-2/3" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" /> <NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
</NCard> </NCard>
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false"> <div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 lg:h-full lg: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">
<NSkeleton class="h-5 w-1/3" /> <NSkeleton class="h-5 w-1/3" />
<NSkeleton class="mt-4 h-8 w-1/2" /> <NSkeleton class="mt-4 h-8 w-1/2" />
<NSkeleton class="mt-5 h-3 w-full" /> <NSkeleton class="mt-5 h-3 w-full" />
</NCard> </NCard>
</div> </div>
<div v-else class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-start"> <div
<div class="flex gap-5"> v-else
<div class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-stretch"
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)]" >
> <NCard class="oc-panel-card h-full border border-slate-200 bg-white/90 shadow-sm backdrop-blur" :bordered="false">
<img <div class="flex gap-5">
v-if="profileData?.avatar_url" <div
:src="profileData.avatar_url" 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)]"
:alt="profileData.username" >
class="h-full w-full object-cover" <img
/> v-if="profileData?.avatar_url"
<template v-else> :src="profileData.avatar_url"
{{ getProfileInitial(profileData) }} :alt="profileData.username"
</template> class="h-full w-full object-cover"
</div> />
<template v-else>
{{ getProfileInitial(profileData) }}
</template>
</div>
<div class="min-w-0"> <div class="min-w-0">
<p class="oc-page-eyebrow">Sky Journal</p> <p class="oc-page-eyebrow">Sky Journal</p>
<h1 class="oc-page-title"> <h1 class="oc-page-title">
{{ pageTitle }} {{ pageTitle }}
</h1> </h1>
<div class="mt-3 flex flex-wrap items-center gap-3"> <div class="mt-3 flex flex-wrap items-center gap-3">
<p class="text-lg font-medium text-slate-700"> <p class="text-lg font-medium text-slate-700">
@{{ profileData?.username || '未知用户' }} @{{ profileData?.username || '未知用户' }}
</p> </p>
<RouterLink v-if="isOwnProfile" to="/profile/settings"> <RouterLink v-if="isOwnProfile" to="/profile/settings">
<button <button
type="button" type="button"
class="oc-icon-button oc-icon-button--small" class="oc-icon-button oc-icon-button--small"
title="个人资料设置" title="个人资料设置"
aria-label="个人资料设置" aria-label="个人资料设置"
> >
<NIcon size="17"> <NIcon size="17">
<Settings /> <Settings />
</NIcon> </NIcon>
</button> </button>
</RouterLink> </RouterLink>
</div> </div>
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600"> <div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
{{ profileSubtitle }} <span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
</p> --> </div>
<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>
</div> </div>
</NCard>
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 lg:h-full lg: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> </div>
<EncyclopediaProgressCard <NCard v-if="!isOwnProfile" class="oc-panel-card-soft h-full border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
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="text-sm text-slate-500">公开贡献</p>
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p> <p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p> <p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
@@ -577,50 +548,12 @@ watch(selectedUploadDate, async newValue => {
</NAlert> </NAlert>
<template v-else> <template v-else>
<div v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <section ref="timelineSectionRef">
<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 <ContributionHeatmap
:cells="contributionCells" :cells="contributionCells"
:total-count="totalShots" :total-count="totalShots"
:active-days="activeUploadDays" :active-days="activeUploadDays"
:current-streak="currentUploadStreak"
:best-streak="longestUploadStreak"
:active-date="selectedUploadDate" :active-date="selectedUploadDate"
:subtitle="isOwnProfile ? '按上传日期统计' : '按公开上传日期统计'"
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'" :title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
@select-date="toggleSelectedUploadDate" @select-date="toggleSelectedUploadDate"
/> />