fix(frontend): adapt to updated profile APIs
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '用户状态更新失败')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user