feat(cloud): sync avatar display and likes API
This commit is contained in:
@@ -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. 端点 · 系统
|
||||
|
||||
@@ -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