feat(cloud): sync avatar display and likes API
This commit is contained in:
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user