fix(frontend): adapt to updated profile APIs
This commit is contained in:
@@ -22,15 +22,11 @@ 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<{
|
||||
@@ -154,31 +150,38 @@ const visibleCells = computed(() => {
|
||||
return cells
|
||||
})
|
||||
|
||||
const maxCount = computed(() => {
|
||||
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0)
|
||||
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
|
||||
})
|
||||
|
||||
function getLevel(count: number) {
|
||||
if (count <= 0) return 0
|
||||
|
||||
const max = Math.max(maxCount.value, 1)
|
||||
const ratio = count / max
|
||||
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
|
||||
|
||||
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
|
||||
if (count <= firstQuartile) return 1
|
||||
if (count <= secondQuartile) return 2
|
||||
if (count <= thirdQuartile) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
function getCellClass(cell: { count: number; inRange: boolean }) {
|
||||
if (!cell.inRange) return 'bg-transparent border-transparent'
|
||||
|
||||
const level = getLevel(cell.count)
|
||||
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]'
|
||||
return `oc-heatmap-cell--level-${level}`
|
||||
}
|
||||
|
||||
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">
|
||||
<span>少</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 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>多</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>
|
||||
|
||||
+18
-3
@@ -23,7 +23,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
function applyAuth(auth: AuthResponse) {
|
||||
setAccessToken(auth.access_token)
|
||||
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() {
|
||||
@@ -58,11 +65,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function updateUsername(username: string) {
|
||||
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
|
||||
const updatedUser = await apiRequest<AuthUser>('/profiles/me', {
|
||||
method: 'PATCH',
|
||||
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) {
|
||||
|
||||
+20
-2
@@ -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, PublicProfile } from '@/types/api'
|
||||
import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
|
||||
export interface ProfileCloudItem {
|
||||
@@ -21,6 +21,8 @@ export interface ProfileCloudItem {
|
||||
is_hidden: boolean
|
||||
cloudTypeName: string
|
||||
cloudTypeRarity: CloudType['rarity']
|
||||
favoriteCount: number
|
||||
isFavorited: boolean
|
||||
}
|
||||
|
||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||
@@ -40,6 +42,8 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +59,7 @@ 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>>({})
|
||||
|
||||
@@ -68,6 +73,10 @@ 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)] ?? '' : ''
|
||||
}
|
||||
@@ -99,9 +108,17 @@ 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 [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
|
||||
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] = []
|
||||
@@ -186,6 +203,7 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
return {
|
||||
getProfile,
|
||||
getClouds,
|
||||
getLikeStats,
|
||||
getError,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
|
||||
@@ -53,6 +53,18 @@
|
||||
--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%);
|
||||
|
||||
+25
-3
@@ -4,6 +4,13 @@ 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
|
||||
}
|
||||
|
||||
@@ -12,12 +19,10 @@ export interface AuthResponse {
|
||||
token_type: 'bearer'
|
||||
expires_in: number
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
export interface PublicProfile {
|
||||
@@ -54,6 +59,24 @@ 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 {
|
||||
@@ -89,5 +112,4 @@ export interface AdminStats {
|
||||
|
||||
export interface AdminUserItem {
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
@@ -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.profile)
|
||||
users.value = result.items.map(item => item.user)
|
||||
}
|
||||
|
||||
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.profile : item)
|
||||
users.value = users.value.map(item => item.id === user.id ? result.user : 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.profile : item)
|
||||
users.value = users.value.map(item => item.id === user.id ? result.user : item)
|
||||
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '用户状态更新失败')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
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'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -148,19 +148,27 @@ async function savePassword() {
|
||||
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<NForm class="grid gap-3" @submit.prevent="savePassword">
|
||||
<NInput
|
||||
v-model:value="newPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="新密码"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="confirmNewPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'confirm-new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="确认新密码"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
@@ -168,14 +176,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>
|
||||
</div>
|
||||
</NForm>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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'
|
||||
@@ -10,7 +9,6 @@ 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'
|
||||
|
||||
@@ -23,7 +21,6 @@ interface TimelineGroup {
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const cloudsStore = useCloudsStore()
|
||||
const encyclopediaStore = useEncyclopediaStore()
|
||||
const profileStore = useProfileStore()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -65,6 +62,7 @@ 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 || '这位用户'}的天空日志`
|
||||
@@ -83,10 +81,6 @@ 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(() => {
|
||||
@@ -184,44 +178,6 @@ 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)
|
||||
@@ -233,10 +189,6 @@ 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', {
|
||||
@@ -448,8 +400,6 @@ async function loadProfilePage(force = false) {
|
||||
|
||||
if (isOwnProfile.value) {
|
||||
await cloudsStore.fetchCloudTypes()
|
||||
await encyclopediaStore.fetchCloudTypes(force)
|
||||
await encyclopediaStore.fetchMyCollection(force)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,77 +425,98 @@ 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="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
|
||||
<NCard class="oc-panel-card 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 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>
|
||||
<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="mt-4 h-8 w-1/2" />
|
||||
<NSkeleton class="mt-5 h-3 w-full" />
|
||||
</NCard>
|
||||
</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
|
||||
v-else
|
||||
class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-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 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 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>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<NCard v-if="!isOwnProfile" class="oc-panel-card-soft h-full 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>
|
||||
@@ -577,50 +548,12 @@ watch(selectedUploadDate, async newValue => {
|
||||
</NAlert>
|
||||
|
||||
<template v-else>
|
||||
<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">
|
||||
<section ref="timelineSectionRef">
|
||||
<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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user