diff --git a/API.md b/API.md index 9e127e4..f8415c3 100644 --- a/API.md +++ b/API.md @@ -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. 端点 · 系统 diff --git a/src/components/cloud/CloudLikeButton.vue b/src/components/cloud/CloudLikeButton.vue new file mode 100644 index 0000000..b479a33 --- /dev/null +++ b/src/components/cloud/CloudLikeButton.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue index 79c9778..8a73fc4 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -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" > - - - + + @{{ displayUsername }} @@ -252,7 +254,8 @@ async function handleLogout() {
- {{ displayUsername.slice(0, 1).toUpperCase() }} + +

@{{ displayUsername }}

diff --git a/src/lib/api.ts b/src/lib/api.ts index ebf0999..bf567b1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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, diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 44d8476..52422cc 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -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, } diff --git a/src/stores/likes.ts b/src/stores/likes.ts new file mode 100644 index 0000000..68bb63b --- /dev/null +++ b/src/stores/likes.ts @@ -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>(new Set()) + const counts = ref>({}) + const pendingIds = ref>(new Set()) + const loadedForUser = ref(null) + let loadPromise: Promise | 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() + for (let page = 1; ; page++) { + const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) }) + const rows = await apiRequest(`/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(`/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(`/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 } +}) diff --git a/src/stores/profile.ts b/src/stores/profile.ts index c3822e2..fed9ae3 100644 --- a/src/stores/profile.ts +++ b/src/stores/profile.ts @@ -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(`/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: '', diff --git a/src/types/api.ts b/src/types/api.ts index 5015fda..fa14979 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 diff --git a/src/types/database.ts b/src/types/database.ts index 012df05..6487840 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -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 diff --git a/src/views/encyclopedia/CloudTypeView.vue b/src/views/encyclopedia/CloudTypeView.vue index 907f76f..0ddf83f 100644 --- a/src/views/encyclopedia/CloudTypeView.vue +++ b/src/views/encyclopedia/CloudTypeView.vue @@ -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()) >

{{ selectedItem.description || '上传者没有留下额外说明。' }}

+
diff --git a/src/views/gallery/GalleryView.vue b/src/views/gallery/GalleryView.vue index 2a7023e..1772d2a 100644 --- a/src/views/gallery/GalleryView.vue +++ b/src/views/gallery/GalleryView.vue @@ -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" /> +