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
+21 -23
View File
@@ -60,7 +60,7 @@
⚠️ **响应不返回总数**,也没有 `has_more` 字段。判断「是否还有下一页」只能依靠「返回数组长度 < `page_size`」。超出数据范围的页返回 `200` + 空数组 `[]`,不是 404。
云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序。
云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序(同刻按云图 ID 倒序)。
### 1.5 共享数据模型
@@ -92,7 +92,7 @@
| `id` | uuid | 用户 ID |
| `name` | string | 用户名 |
| `email` | string | 邮箱(注意 2.3 第 7 条的暴露范围问题) |
| `avatar_id` | uuid\|null | 头像 ID(当前恒为 `null`,头像功能未上线) |
| `image` | string\|null | 用户头像 URL,由 Better Auth 管理;未设置时为 `null` |
| `cloud_count` | int | 云图数(该用户上传的云图总数) |
| `received_like_count` | int | 收到点赞数(名下云图获赞总和) |
| `last_online` | string\|null | 最后在线时间(ISO 8601) |
@@ -243,6 +243,8 @@ const { data: session } = await authClient.getSession();
需要业务资料、统计及角色时,再请求 `GET /profile/me`,不要将 Better Auth 的 `user` 对象当作第 1.5 节的 UserProfile。完整客户端方法见 [Better Auth 会话管理](https://better-auth.com/docs/concepts/session-management)。
头像使用 Better Auth 的 `user.image` 字段。前端可调用 `authClient.updateUser({ image: "https://example.com/avatar.png" })` 设置头像 URL;业务资料接口和管理员用户列表均返回同一 `image` 值。原 `avatar_id` 字段已移除。
所有受保护的业务接口都再次检查会话、邮箱验证状态、禁用状态及角色。**业务接口**在无会话、邮箱未验证或帐号被禁用时返回 `401`;会话有效但角色或资源权限不足时返回 `403`。管理员修改角色或密码后会撤销目标用户的全部会话,前端应刷新登录状态。
两种可选认证端点行为不同:`GET /cloud/:cloud_id` 对无效/过期凭证按匿名处理;`GET|HEAD /image/:cloud_id/:variant` 在显式提供无效凭证时返回 `401`。同时发送 `Authorization` 和 Cookie 时,业务接口优先使用 Bearer 凭证。
@@ -428,7 +430,7 @@ if (!response.ok) throw new Error("邮箱验证失败");
| 404 | 云类型不存在 | `云类型不存在` | 提示类型不存在 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
### 5.5 `POST /cloud/:cloud_id/like` — 点赞
### 5.5 `PUT /cloud/:cloud_id/like` — 点赞
- **认证**:需登录(任意角色)
- **请求体**:无
@@ -450,7 +452,7 @@ if (!response.ok) throw new Error("邮箱验证失败");
| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。
**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。旧 `POST /cloud/:cloud_id/like` 已移除;前端改用 `PUT`。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。
### 5.6 `DELETE /cloud/:cloud_id/like` — 取消点赞
@@ -675,7 +677,7 @@ if (!response.ok) throw new Error("邮箱验证失败");
"id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
"name": "cloudwatcher",
"email": "watcher@example.com",
"avatar_id": null,
"image": "https://example.com/avatar.png",
"cloud_count": 7,
"received_like_count": 42,
"last_online": "2026-08-15T08:30:00.000Z",
@@ -720,12 +722,13 @@ if (!response.ok) throw new Error("邮箱验证失败");
### 6.3 `GET /profile/me/likes` — 我点赞过的云图
- **认证**:需登录
- **Query 参数**:分页参数 `page`、`page_size`,见 1.4
**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序。
**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序、同刻按云图 ID 倒序;默认返回第 1 页的 50 条,最多每页 100 条。超出范围的页返回 `[]`。
**注意事项**:包含**所有状态**的云图(含待审核/已驳回/已隐藏的),因为这些是你自己点过赞的——展示时如需隐藏非公开项请自行过滤 `status` 与 `is_hidden`。
**错误**:500 `服务器发生内部错误`。
**错误**:分页参数非法时返回 400(见 2.1);服务端异常返回 500 `服务器发生内部错误`。
### 6.4 `GET /profile/me/clouds` — 我上传的云图
@@ -753,22 +756,7 @@ if (!response.ok) throw new Error("邮箱验证失败");
**注意事项**:⚠️ 响应包含对方 `email`(2.3 第 7 条)。展示他人资料页时不应展示邮箱字段。
### 6.6 `GET /profile/:user_id/likes` — 查看用户点赞记录
- **认证**:需登录,且为**本人或 admin**
**路径参数**:`user_id`(uuid)。
**成功响应**:`200`,CloudInfo 数组,按点赞时间倒序,结构同 5.1。
**错误**:
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 403 | 查看他人且非 admin | `无权查看该用户的点赞记录` | 前端应在进入页面前判断身份,避免触发 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
### 6.7 `GET /profile/:user_id/clouds` — 查看用户上传的云图
### 6.6 `GET /profile/:user_id/clouds` — 查看用户上传的云图
- **认证**:需登录,且为**本人或 admin**
@@ -1084,6 +1072,16 @@ if (!response.ok) throw new Error("邮箱验证失败");
**注意事项**:管理员删除不受审核状态或隐藏状态限制。删除会级联删除该云图的点赞记录,并尽力清理 MinIO 中的图片对象;清理失败不报错,可能残留孤儿对象(可接受)。删除后前端应立即刷新相关列表。
### 8.12 `GET /admin/users/:user_id/likes` — 查看用户点赞记录
- **认证**:admin
- **路径参数**:`user_id`(uuid)
- **Query 参数**:分页参数 `page`、`page_size`,见 1.4
**成功响应**:`200`,与 6.3 相同的 CloudInfo 数组和排序,包含该用户已点赞但后来被隐藏或改判的云图;没有点赞或页码超出范围时返回 `[]`。
**错误**:`user_id` 或分页参数非法时返回 400(见 2.1);用户不存在时返回 404 `{ "status_code": 404, "error": "用户不存在" }`;服务端异常返回 500 `服务器发生内部错误`。非管理员统一返回 403(本章规则)。旧 `GET /profile/:user_id/likes` 已移除;本人使用 6.3,管理员使用本端点。
---
## 9. 端点 · 系统
+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">