feat(cloud): sync avatar display and likes API

This commit is contained in:
2026-09-28 17:12:49 +08:00
parent 6445687d56
commit c3e49bd9b4
13 changed files with 239 additions and 37 deletions
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, NIcon, useMessage } from 'naive-ui'
import { Heart } from '@vicons/tabler'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useLikesStore } from '@/stores/likes'
const props = defineProps<{ cloudId: string; count: number }>()
const route = useRoute()
const authStore = useAuthStore()
const likesStore = useLikesStore()
const message = useMessage()
const loading = ref(false)
const error = ref('')
const liked = computed(() => likesStore.isLiked(props.cloudId))
const count = computed(() => likesStore.countFor(props.cloudId, props.count))
watch(() => [authStore.user?.id, props.cloudId], async ([userId]) => {
error.value = ''
if (!userId) return
loading.value = true
try {
await likesStore.ensureLoaded()
} catch (e) {
error.value = e instanceof Error ? e.message : '点赞状态加载失败'
} finally {
loading.value = false
}
}, { immediate: true })
async function toggleLike() {
error.value = ''
try {
await likesStore.toggle(props.cloudId, props.count)
message.success(liked.value ? '已点赞' : '已取消点赞')
} catch (e) {
error.value = e instanceof Error ? e.message : '点赞操作失败'
}
}
</script>
<template>
<div class="mt-5 flex flex-wrap items-center gap-3">
<NButton
v-if="authStore.isLoggedIn"
type="default"
:class="['oc-panel-button', liked ? 'oc-panel-button--teal' : 'oc-panel-button--neutral']"
:loading="loading || likesStore.isPending(cloudId)"
@click="toggleLike"
>
<template #icon><NIcon><Heart /></NIcon></template>
{{ liked ? '取消点赞' : '点赞' }}
</NButton>
<RouterLink
v-else
:to="{ path: '/login', query: { redirect: route.fullPath } }"
class="oc-panel-button oc-panel-button--neutral no-underline"
>登录后点赞</RouterLink>
<span class="text-sm text-slate-600">{{ count }} 次点赞</span>
<span v-if="error" class="text-sm text-rose-700">{{ error }}</span>
</div>
</template>
+7 -4
View File
@@ -19,6 +19,9 @@ const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
const avatarFailed = ref(false)
watch(() => authStore.user?.image, () => { avatarFailed.value = false })
let lastScrollY = 0
let accountCloseTimer: number | null = null
@@ -237,9 +240,8 @@ async function handleLogout() {
aria-haspopup="menu"
:aria-expanded="accountCardOpen"
>
<NIcon size="16" class="shrink-0">
<User />
</NIcon>
<img v-if="authStore.user?.image && !avatarFailed" :src="authStore.user.image" alt="" class="h-5 w-5 shrink-0 object-cover" @error="avatarFailed = true" />
<NIcon v-else size="16" class="shrink-0"><User /></NIcon>
<span class="truncate">@{{ displayUsername }}</span>
</button>
@@ -252,7 +254,8 @@ async function handleLogout() {
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
{{ displayUsername.slice(0, 1).toUpperCase() }}
<img v-if="authStore.user?.image && !avatarFailed" :src="authStore.user.image" :alt="displayUsername" class="h-full w-full object-cover" @error="avatarFailed = true" />
<template v-else>{{ displayUsername.slice(0, 1).toUpperCase() }}</template>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-slate-950">@{{ displayUsername }}</p>
+1 -1
View File
@@ -139,7 +139,7 @@ export function toAuthUser(profile: BackendUserProfile): AuthUser {
id: profile.id,
email: profile.email,
username: profile.name,
avatar_id: profile.avatar_id,
image: profile.image,
role: profile.role,
is_disabled: profile.is_disabled,
cloud_count: profile.cloud_count,
+2 -1
View File
@@ -20,7 +20,7 @@ export const useAuthStore = defineStore('auth', () => {
profile.value = {
id: normalized.id,
username: normalized.username,
avatar_id: normalized.avatar_id,
image: normalized.image,
role: normalized.role,
is_disabled: normalized.is_disabled,
created_at: normalized.created_at,
@@ -127,6 +127,7 @@ export const useAuthStore = defineStore('auth', () => {
resetPassword,
logout,
updateUsername,
refreshProfile: fetchMe,
updatePassword,
initialize,
}
+105
View File
@@ -0,0 +1,105 @@
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ActionResponse, BackendCloudInfo } from '@/types/api'
const PAGE_SIZE = 100
export const useLikesStore = defineStore('likes', () => {
const authStore = useAuthStore()
const likedIds = ref<Set<string>>(new Set())
const counts = ref<Record<string, number>>({})
const pendingIds = ref<Set<string>>(new Set())
const loadedForUser = ref<string | null>(null)
let loadPromise: Promise<void> | null = null
let loadingForUser: string | null = null
watch(() => authStore.user?.id, () => {
likedIds.value = new Set()
counts.value = {}
pendingIds.value = new Set()
loadedForUser.value = null
loadPromise = null
loadingForUser = null
})
function isLiked(cloudId: string) {
return likedIds.value.has(cloudId)
}
function countFor(cloudId: string, initialCount: number) {
return counts.value[cloudId] ?? initialCount
}
function isPending(cloudId: string) {
return pendingIds.value.has(cloudId)
}
async function ensureLoaded() {
const userId = authStore.user?.id
if (!userId || loadedForUser.value === userId) return
if (loadPromise && loadingForUser === userId) return loadPromise
loadingForUser = userId
const request = (async () => {
const nextIds = new Set<string>()
for (let page = 1; ; page++) {
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
const rows = await apiRequest<BackendCloudInfo[]>(`/profile/me/likes?${params}`)
rows.forEach(row => nextIds.add(row.id))
if (rows.length < PAGE_SIZE) break
}
if (authStore.user?.id === userId) {
likedIds.value = nextIds
loadedForUser.value = userId
}
})()
loadPromise = request
try {
await request
} finally {
if (loadPromise === request) {
loadPromise = null
loadingForUser = null
}
}
}
async function toggle(cloudId: string, initialCount: number) {
const userId = authStore.user?.id
if (!userId) throw new Error('请先登录后点赞。')
await ensureLoaded()
if (authStore.user?.id !== userId) throw new Error('登录状态已改变,请重试。')
if (isPending(cloudId)) return
const wasLiked = isLiked(cloudId)
pendingIds.value = new Set(pendingIds.value).add(cloudId)
try {
await apiRequest<ActionResponse>(`/cloud/${cloudId}/like`, {
method: wasLiked ? 'DELETE' : 'PUT',
})
if (authStore.user?.id !== userId) return
const nextIds = new Set(likedIds.value)
if (wasLiked) nextIds.delete(cloudId)
else nextIds.add(cloudId)
likedIds.value = nextIds
counts.value[cloudId] = Math.max(0, countFor(cloudId, initialCount) + (wasLiked ? -1 : 1))
try {
const cloud = await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`, { auth: false })
counts.value[cloudId] = cloud.received_like_count
if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile()
} catch {
// The mutation succeeded; keep the local count if a refresh fails.
}
} finally {
const nextPending = new Set(pendingIds.value)
nextPending.delete(cloudId)
pendingIds.value = nextPending
}
}
return { isLiked, countFor, isPending, ensureLoaded, toggle }
})
+12 -2
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ActionResponse, ApiCloud, BackendCloudInfo } from '@/types/api'
import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api'
import type { Profile } from '@/types/database'
export interface ProfileCloudItem {
@@ -101,12 +101,22 @@ export const useProfileStore = defineStore('profile-page', () => {
if (isOwnProfile && authStore.profile) {
profilesByKey.value[identifier] = authStore.profile
} else if (identifier === authStore.profile?.username && authStore.profile) {
profilesByKey.value[identifier] = authStore.profile
} else {
const owner = backendRows[0]?.owner
let publicProfile: BackendUserProfile | null = null
if (owner?.id && authStore.isLoggedIn) {
try {
publicProfile = await apiRequest<BackendUserProfile>(`/profile/${owner.id}`)
} catch {
// Public clouds can still be displayed if the profile lookup fails.
}
}
profilesByKey.value[identifier] = {
id: owner?.id || identifier,
username: owner?.name || identifier,
avatar_id: null,
image: publicProfile?.image ?? null,
role: 'user',
is_disabled: false,
created_at: '',
+2 -2
View File
@@ -4,7 +4,7 @@ export interface BackendUserProfile {
id: string
name: string
email: string
avatar_id: string | null
image: string | null
cloud_count: number
received_like_count: number
last_online: string | null
@@ -62,7 +62,7 @@ export interface AuthUser {
id: string
email: string
username: string
avatar_id: string | null
image: string | null
role: Profile['role']
is_disabled: boolean
cloud_count: number
+1 -1
View File
@@ -27,7 +27,7 @@ export interface Cloud {
export interface Profile {
id: string
username: string
avatar_id: string | null
image: string | null
role: 'user' | 'admin'
is_disabled: boolean
created_at: string
+2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { NAlert, NButton, NEmpty, NSkeleton } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
@@ -137,6 +138,7 @@ watch(() => route.params.id, () => loadPage())
>
<p class="text-sm leading-7 text-slate-700">{{ selectedItem.description || '上传者没有留下额外说明。' }}</p>
<MiniLocationMap :latitude="selectedItem.latitude" :longitude="selectedItem.longitude" />
<CloudLikeButton :cloud-id="selectedItem.id" :count="selectedItem.received_like_count" />
</ImageDetailModal>
</div>
</template>
+4
View File
@@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router'
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
@@ -29,6 +30,7 @@ interface GalleryCloud {
cloudTypeName: string
cloudTypeRarity: number
username: string
receivedLikeCount: number
}
const PAGE_SIZE = 50
@@ -98,6 +100,7 @@ function toGalleryCloud(row: ApiCloud) {
cloudTypeName: row.cloud_type_name || '未知',
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
username: row.username || '匿名',
receivedLikeCount: row.received_like_count,
} satisfies GalleryCloud
}
@@ -551,6 +554,7 @@ onUnmounted(() => {
:latitude="selectedCloud.latitude"
:longitude="selectedCloud.longitude"
/>
<CloudLikeButton :cloud-id="selectedCloud.id" :count="selectedCloud.receivedLikeCount" />
<template v-if="selectedCloudIsMine" #actions>
<div class="flex items-center justify-between gap-3">
+4
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
import { apiRequest, toApiCloud } from '@/lib/api'
@@ -22,6 +23,7 @@ interface CloudMarkerData {
username: string
capturedAt: string
createdAt: string
receivedLikeCount: number
}
const mapEl = ref<HTMLDivElement>()
@@ -417,6 +419,7 @@ function toCloudMarker(row: ApiCloud): CloudMarkerData {
username: row.username || '匿名',
capturedAt: row.captured_at || row.created_at,
createdAt: row.created_at,
receivedLikeCount: row.received_like_count,
}
}
@@ -862,6 +865,7 @@ onUnmounted(() => {
:latitude="previewCloud.latitude"
:longitude="previewCloud.longitude"
/>
<CloudLikeButton :cloud-id="previewCloud.id" :count="previewCloud.receivedLikeCount" />
</ImageDetailModal>
<QuickUploadModal
+14 -3
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
import { Settings } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
@@ -54,7 +55,11 @@ const errorMsg = computed(() => {
if (!viewedIdentifier.value) return '未找到要查看的用户。'
return profileStore.getError(viewedIdentifier.value, isOwnProfile.value)
})
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedIdentifier.value))
const profileData = computed<Profile | null>(() => isOwnProfile.value
? authStore.profile
: profileStore.getProfile(viewedIdentifier.value))
const profileImageFailed = ref(false)
watch(() => profileData.value?.image, () => { profileImageFailed.value = false })
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedIdentifier.value, isOwnProfile.value))
const receivedLikes = computed(() => isOwnProfile.value ? authStore.user?.received_like_count || 0 : 0)
@@ -439,10 +444,11 @@ watch(selectedUploadDate, async newValue => {
class="profile-avatar 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"
>
<img
v-if="profileData?.avatar_id"
:src="profileData.avatar_id"
v-if="profileData?.image && !profileImageFailed"
:src="profileData.image"
:alt="profileData.username"
class="h-full w-full object-cover"
@error="profileImageFailed = true"
/>
<template v-else>
{{ getProfileInitial(profileData) }}
@@ -757,6 +763,11 @@ watch(selectedUploadDate, async newValue => {
:latitude="selectedCloud.latitude"
:longitude="selectedCloud.longitude"
/>
<CloudLikeButton
v-if="selectedCloud.status === 'approved' && !selectedCloud.is_hidden"
:cloud-id="selectedCloud.id"
:count="selectedCloud.receivedLikeCount"
/>
<template v-if="isOwnProfile" #actions>
<div class="flex items-center justify-between gap-3">