Skip to content

Commit ffcdec3

Browse files
committed
feat: add UserHoverCard component and user hover functionality
- Introduced UserHoverCard component for displaying user information on hover. - Updated QuotedPost, ProjectInfoCard, UserBlocked, UserFollowers, UserFollowing components to utilize UserHoverCard. - Implemented user hover directive to enhance user mentions with hover cards in v-html content. - Added composable useUserHoverCard for managing user data fetching and caching. - Enhanced user experience with loading states and error handling in hover cards.
1 parent 1b71067 commit ffcdec3

File tree

11 files changed

+1124
-61
lines changed

11 files changed

+1124
-61
lines changed

src/components/UserHoverCard.vue

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
<template>
2+
<div
3+
class="user-hover-card-wrapper"
4+
:style="{ display: inline ? 'inline' : 'block' }"
5+
@mouseenter="handleMouseEnter"
6+
@mouseleave="handleMouseLeave"
7+
>
8+
<!-- 触发区域:包裹任意内容 -->
9+
<slot />
10+
11+
<!-- 悬浮卡片 (teleport 到 body) -->
12+
<v-menu
13+
v-model="isVisible"
14+
:activator="'parent'"
15+
:open-on-hover="false"
16+
:close-on-content-click="false"
17+
:open-on-click="false"
18+
location="bottom start"
19+
:offset="8"
20+
:max-width="360"
21+
:min-width="300"
22+
transition="scale-transition"
23+
:z-index="2100"
24+
>
25+
<v-card
26+
class="user-hover-popup"
27+
elevation="8"
28+
rounded="xl"
29+
@mouseenter="onCardMouseEnter"
30+
@mouseleave="onCardMouseLeave"
31+
>
32+
<!-- 加载骨架屏 -->
33+
<template v-if="isLoading && !userData">
34+
<v-card-text class="pa-4">
35+
<div class="d-flex align-center mb-3">
36+
<v-skeleton-loader type="avatar" class="mr-3" />
37+
<div class="flex-grow-1">
38+
<v-skeleton-loader type="text" width="120" class="mb-1" />
39+
<v-skeleton-loader type="text" width="80" />
40+
</div>
41+
</div>
42+
<v-skeleton-loader type="paragraph" />
43+
<div class="d-flex gap-4 mt-3">
44+
<v-skeleton-loader type="text" width="60" />
45+
<v-skeleton-loader type="text" width="60" />
46+
<v-skeleton-loader type="text" width="60" />
47+
</div>
48+
</v-card-text>
49+
</template>
50+
51+
<!-- 用户信息 -->
52+
<template v-else-if="userData && userData.id">
53+
<v-card-text class="pa-4 pb-2">
54+
<!-- 头部:头像 + 关注按钮 -->
55+
<div class="d-flex align-start justify-space-between mb-2">
56+
<v-avatar
57+
size="56"
58+
class="user-hover-avatar"
59+
@click="goToProfile"
60+
>
61+
<v-img
62+
v-if="userAvatar"
63+
:src="userAvatar"
64+
:alt="userData.display_name"
65+
/>
66+
<v-icon v-else icon="mdi-account" size="28" />
67+
</v-avatar>
68+
69+
<v-btn
70+
v-if="showFollowButton"
71+
:color="'primary'"
72+
:variant="isFollowing ? 'outlined' : 'flat'"
73+
:prepend-icon="isFollowing ? 'mdi-check' : 'mdi-account-plus'"
74+
:loading="followLoading"
75+
rounded="pill"
76+
size="small"
77+
@click.stop="toggleFollow"
78+
>
79+
{{ isFollowing ? '已关注' : '关注' }}
80+
</v-btn>
81+
</div>
82+
83+
<!-- 名称 -->
84+
<div class="user-hover-names" @click="goToProfile">
85+
<div class="user-hover-display-name text-body-1 font-weight-bold">
86+
{{ userData.display_name || userData.username }}
87+
<v-icon
88+
v-if="isAdmin"
89+
icon="mdi-shield-check"
90+
size="16"
91+
color="primary"
92+
class="ml-1"
93+
/>
94+
</div>
95+
<div class="user-hover-username text-body-2 text-medium-emphasis">
96+
@{{ userData.username }}
97+
</div>
98+
</div>
99+
100+
101+
<!-- motto -->
102+
<div
103+
v-if="userData.motto"
104+
class="user-hover-motto text-body-2 text-medium-emphasis mt-1 "
105+
>
106+
"{{ userData.motto }}"
107+
</div>
108+
</v-card-text>
109+
110+
<!-- 数据统计 -->
111+
<v-card-text class="px-4 pt-0 pb-3">
112+
<div class="d-flex gap-4 user-hover-stats">
113+
<router-link
114+
:to="`/${userData.username}`"
115+
class="user-hover-stat"
116+
@click="close"
117+
>
118+
<span class="font-weight-bold">{{ formatCount(userData.project_count) }}</span>
119+
<span class="text-medium-emphasis ml-1">作品</span>
120+
</router-link>
121+
<router-link
122+
:to="`/${userData.username}?tab=following`"
123+
class="user-hover-stat"
124+
@click="close"
125+
>
126+
<span class="font-weight-bold">{{ formatCount(userData.following_count) }}</span>
127+
<span class="text-medium-emphasis ml-1">关注</span>
128+
</router-link>
129+
<router-link
130+
:to="`/${userData.username}?tab=followers`"
131+
class="user-hover-stat"
132+
@click="close"
133+
>
134+
<span class="font-weight-bold">{{ formatCount(userData.followers_count) }}</span>
135+
<span class="text-medium-emphasis ml-1">粉丝</span>
136+
</router-link>
137+
</div>
138+
</v-card-text>
139+
</template>
140+
141+
<!-- 加载失败 -->
142+
<template v-else>
143+
<v-card-text class="pa-4 text-center text-medium-emphasis">
144+
<v-icon icon="mdi-account-alert" size="32" class="mb-2" />
145+
<div>无法加载用户信息</div>
146+
</v-card-text>
147+
</template>
148+
</v-card>
149+
</v-menu>
150+
</div>
151+
</template>
152+
153+
<script setup>
154+
import { computed, toRef, onBeforeUnmount } from "vue";
155+
import { useRouter } from "vue-router";
156+
import { useUserHoverCard } from "@/composables/useUserHoverCard";
157+
import { localuser } from "@/services/localAccount";
158+
import request from "@/axios/axios";
159+
import { ref } from "vue";
160+
161+
const props = defineProps({
162+
/** 目标用户名(必填) */
163+
username: {
164+
type: String,
165+
required: true,
166+
},
167+
/** 是否以 inline 方式显示(用于行内文本如 @提及) */
168+
inline: {
169+
type: Boolean,
170+
default: false,
171+
},
172+
/** 悬停延迟(毫秒) */
173+
hoverDelay: {
174+
type: Number,
175+
default: 400,
176+
},
177+
/** 离开延迟(毫秒) */
178+
leaveDelay: {
179+
type: Number,
180+
default: 200,
181+
},
182+
});
183+
184+
const router = useRouter();
185+
186+
const {
187+
isVisible,
188+
isLoading,
189+
userData,
190+
onMouseEnter,
191+
onMouseLeave,
192+
onCardMouseEnter,
193+
onCardMouseLeave,
194+
close,
195+
} = useUserHoverCard({
196+
hoverDelay: props.hoverDelay,
197+
leaveDelay: props.leaveDelay,
198+
});
199+
200+
// ==================== 头像 ====================
201+
const userAvatar = computed(() => {
202+
if (!userData.value?.avatar) return "";
203+
return localuser.getUserAvatar(userData.value.avatar);
204+
});
205+
206+
// ==================== 简介截断 ====================
207+
const truncatedBio = computed(() => {
208+
const bio = userData.value?.bio || "";
209+
return bio.length > 120 ? bio.slice(0, 120) + "" : bio;
210+
});
211+
212+
// ==================== 角色判断 ====================
213+
const isAdmin = computed(() => {
214+
return userData.value?.type === "administrator" || userData.value?.role === "admin";
215+
});
216+
217+
// ==================== 关注相关 ====================
218+
const isFollowing = ref(false);
219+
const followLoading = ref(false);
220+
221+
const showFollowButton = computed(() => {
222+
// 不显示自己的关注按钮
223+
const currentUser = localuser.user?.value;
224+
if (!currentUser || !currentUser.id) return false;
225+
if (!userData.value?.id) return false;
226+
return currentUser.id !== userData.value.id;
227+
});
228+
229+
async function checkFollowStatus() {
230+
if (!showFollowButton.value) return;
231+
try {
232+
const { data } = await request.get(`/follows/relationships/${userData.value.id}`);
233+
isFollowing.value = data?.data?.isFollowing ?? false;
234+
} catch {
235+
isFollowing.value = false;
236+
}
237+
}
238+
239+
async function toggleFollow() {
240+
if (!userData.value?.id || followLoading.value) return;
241+
followLoading.value = true;
242+
try {
243+
if (isFollowing.value) {
244+
await request.delete(`/follows/${userData.value.id}`);
245+
isFollowing.value = false;
246+
} else {
247+
await request.post(`/follows/${userData.value.id}`);
248+
isFollowing.value = true;
249+
}
250+
} catch (e) {
251+
console.error("[UserHoverCard] 关注操作失败:", e);
252+
} finally {
253+
followLoading.value = false;
254+
}
255+
}
256+
257+
// 监听用户数据加载完成后检查关注状态
258+
import { watch } from "vue";
259+
watch(userData, (val) => {
260+
if (val?.id) {
261+
checkFollowStatus();
262+
}
263+
});
264+
265+
// ==================== 数字格式化 ====================
266+
function formatCount(num) {
267+
if (num == null) return "0";
268+
num = Number(num);
269+
if (num >= 10000) return (num / 10000).toFixed(1) + "";
270+
if (num >= 1000) return (num / 1000).toFixed(1) + "k";
271+
return String(num);
272+
}
273+
274+
// ==================== 事件处理 ====================
275+
function handleMouseEnter() {
276+
onMouseEnter(props.username);
277+
}
278+
279+
function handleMouseLeave() {
280+
onMouseLeave();
281+
}
282+
283+
function goToProfile() {
284+
close();
285+
router.push(`/${userData.value?.username || props.username}`);
286+
}
287+
288+
// ==================== 清理 ====================
289+
onBeforeUnmount(() => {
290+
close();
291+
});
292+
</script>
293+
294+
<style scoped>
295+
.user-hover-card-wrapper {
296+
cursor: pointer;
297+
}
298+
299+
.user-hover-popup {
300+
overflow: visible;
301+
}
302+
303+
.user-hover-avatar {
304+
cursor: pointer;
305+
transition: opacity 0.2s;
306+
}
307+
308+
.user-hover-avatar:hover {
309+
opacity: 0.85;
310+
}
311+
312+
.user-hover-names {
313+
cursor: pointer;
314+
}
315+
316+
.user-hover-names:hover .user-hover-display-name {
317+
text-decoration: underline;
318+
}
319+
320+
.user-hover-bio {
321+
display: -webkit-box;
322+
-webkit-line-clamp: 3;
323+
line-clamp: 3;
324+
-webkit-box-orient: vertical;
325+
overflow: hidden;
326+
word-break: break-word;
327+
line-height: 1.5;
328+
}
329+
330+
.user-hover-motto {
331+
opacity: 0.8;
332+
}
333+
334+
.user-hover-stats {
335+
font-size: 0.875rem;
336+
}
337+
338+
.user-hover-stat {
339+
text-decoration: none;
340+
color: inherit;
341+
transition: opacity 0.15s;
342+
}
343+
344+
.user-hover-stat:hover {
345+
text-decoration: underline;
346+
}
347+
</style>

0 commit comments

Comments
 (0)