feat(users): implement user following system and following feed

Add follow/unfollow actions and social stats to user profiles
Introduce Following feed scope in Life view
Add notifications for new followers
This commit is contained in:
2026-05-04 15:49:57 +08:00
parent 016364a8b8
commit 8cb8190554
11 changed files with 472 additions and 18 deletions

View File

@@ -7,6 +7,7 @@ import {
iconBell,
iconCheck,
iconComment,
iconProfile,
iconReactionFun,
iconReactionHelpful,
iconReactionLike,
@@ -264,7 +265,8 @@ function targetLabel(type: NotificationTargetType) {
const labels: Record<NotificationTargetType, string> = {
'life-post': t('notifications.targetLifePost'),
'life-comment': t('notifications.targetLifeComment'),
'discussion-comment': t('notifications.targetDiscussionComment')
'discussion-comment': t('notifications.targetDiscussionComment'),
'profile-user': t('notifications.targetProfile')
};
return labels[type];
}
@@ -285,6 +287,9 @@ function notificationText(notification: NotificationItem) {
reaction: reactionLabel(notification.reactionType)
});
}
if (notification.type === 'user_follow') {
return t('notifications.userFollow', { actor: actorName(notification) });
}
const target = targetLabel(notification.target.type);
if (notification.moderationStatus === 'approved') {
@@ -315,6 +320,9 @@ function notificationIcon(notification: NotificationItem) {
if (notification.type === 'life_post_reaction') {
return reactionIcon(notification.reactionType);
}
if (notification.type === 'user_follow') {
return iconProfile;
}
return notification.moderationStatus === 'approved' ? iconCheck : iconWarning;
}

View File

@@ -373,9 +373,10 @@ export type NotificationType =
| 'life_comment_reply'
| 'discussion_comment_reply'
| 'life_post_reaction'
| 'user_follow'
| 'moderation_result';
export type NotificationModerationStatus = Extract<AiModerationStatus, 'approved' | 'rejected' | 'failed'>;
export type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
export type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'profile-user';
export interface LifePost {
id: number;
@@ -467,6 +468,7 @@ export interface NotificationTarget {
id: number;
path: string;
lifePostId: number | null;
profileUserId: number | null;
lifeCommentId: number | null;
discussionCommentId: number | null;
entityType: DiscussionEntityType | null;
@@ -585,9 +587,19 @@ export interface PublicProfileContribution {
lastContributedAt: string | null;
}
export type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends';
export interface PublicProfileSocial {
followerCount: number;
followingCount: number;
friendCount: number;
viewerRelation: PublicProfileViewerRelation;
}
export interface PublicUserProfile {
user: PublicProfileUser;
stats: PublicProfileStats;
social: PublicProfileSocial;
contributions: PublicProfileContribution[];
}
@@ -1119,6 +1131,21 @@ export const api = {
markAllNotificationsRead: () => sendJson<{ unreadCount: number }>('/api/notifications/read-all', 'POST', {}),
logout: () => postEmpty('/api/auth/logout'),
publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`),
followUser: (id: string | number) => sendJson<{ profile: PublicUserProfile }>(`/api/users/${id}/follow`, 'PUT', {}),
unfollowUser: (id: string | number) => deleteAndGetJson<{ profile: PublicUserProfile }>(`/api/users/${id}/follow`),
followingLifePosts: (params: LifePostsParams = {}) =>
getJson<LifePostsPage>(
`/api/life-posts/following${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
search: params.search,
categoryId: params.categoryId,
language: params.language,
gameVersionId: params.gameVersionId,
rateable: params.rateable === null ? undefined : params.rateable,
sort: params.sort
})}`
),
userLifePosts: (id: string | number, params: ProfileActivityParams = {}) =>
getJson<LifePostsPage>(
`/api/users/${id}/life-posts${buildQuery({

View File

@@ -6387,6 +6387,17 @@ button:disabled,
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.profile-stat-strip--social {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.profile-follow-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.profile-stat-grid {
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
}

View File

@@ -61,6 +61,7 @@ type LifeCommentPageState = {
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type LifeFeedScope = 'all' | 'following';
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
@@ -79,6 +80,7 @@ const activeLanguageCode = ref('all');
const activeGameVersionId = ref('all');
const activeRateableFilter = ref('all');
const activeSort = ref<LifePostSort>('latest');
const activeFeedScope = ref<LifeFeedScope>('all');
const body = ref('');
const selectedCategoryId = ref('');
const selectedGameVersionId = ref('');
@@ -181,6 +183,10 @@ const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() =
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
]);
const feedScopeOptions = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allFeed') },
{ value: 'following', label: t('pages.life.followingFeed') }
]);
const defaultLifeCategoryId = computed(() => {
const category = lifeCategories.value.find((item) => item.isDefault);
return category ? String(category.id) : '';
@@ -196,6 +202,7 @@ async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
activeFeedScope.value = 'all';
authReady.value = true;
return;
}
@@ -205,6 +212,7 @@ async function loadCurrentUser() {
currentUser.value = response.user;
} catch {
currentUser.value = null;
activeFeedScope.value = 'all';
setAuthToken(null);
} finally {
authReady.value = true;
@@ -265,7 +273,7 @@ async function loadPosts() {
loadMorePaused.value = false;
try {
const page = await api.lifePosts({
const params = {
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
@@ -273,7 +281,8 @@ async function loadPosts() {
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
});
};
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
if (requestId !== postsRequestId) {
return;
}
@@ -309,7 +318,7 @@ async function loadMorePosts() {
loadError.value = '';
try {
const page = await api.lifePosts({
const params = {
cursor,
limit: lifePostPageSize,
search: searchQuery.value,
@@ -318,7 +327,8 @@ async function loadMorePosts() {
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
});
};
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
if (requestId !== postsRequestId) {
return;
}
@@ -447,7 +457,7 @@ async function submitPost() {
replacePost(updated);
} else {
const created = await api.createLifePost(payload());
if (activeSort.value !== 'latest') {
if (activeSort.value !== 'latest' || activeFeedScope.value === 'following') {
void loadPosts();
} else if (matchesCurrentFilters(created)) {
posts.value = [created, ...posts.value];
@@ -1205,6 +1215,9 @@ watch(activeRateableFilter, () => {
watch(activeSort, () => {
void loadPosts();
});
watch(activeFeedScope, () => {
void loadPosts();
});
watch(locale, () => {
void loadLanguages();
void loadLifeCategories();
@@ -1220,8 +1233,10 @@ onMounted(() => {
void loadLifeCategories();
void loadPosts();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
void loadPosts();
void (async () => {
await loadCurrentUser();
await loadPosts();
})();
});
});
@@ -1382,6 +1397,13 @@ onUnmounted(() => {
@close="closeReactionUsersModal"
/>
<Tabs
v-if="currentUser"
id="life-feed-scope"
v-model="activeFeedScope"
:tabs="feedScopeOptions"
:label="t('pages.life.feedScope')"
/>
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />

View File

@@ -70,10 +70,12 @@ const commentFilter = ref<CommentFilter>('all');
const loading = ref(true);
const busy = ref(false);
const passwordBusy = ref(false);
const followBusy = ref(false);
const message = ref('');
const errorMessage = ref('');
const passwordMessage = ref('');
const passwordErrorMessage = ref('');
const followErrorMessage = ref('');
const referralSummaryMessage = ref('');
const referralSummaryErrorMessage = ref('');
const referralMessage = ref('');
@@ -111,6 +113,18 @@ const hasChanges = computed(() => {
if (!user || !canShowAccount.value) return false;
return trimmedDisplayName.value !== user.displayName;
});
const canFollowProfile = computed(() => {
const user = currentUser.value;
const target = profile.value?.user;
return Boolean(user && target && user.id !== target.id && user.permissions.includes('users.follow'));
});
const followButtonLabel = computed(() => {
const relation = profile.value?.social.viewerRelation ?? 'none';
if (relation === 'friends') return t('pages.profile.friend');
if (relation === 'following') return t('pages.profile.following');
if (relation === 'followed-by') return t('pages.profile.followBack');
return t('pages.profile.follow');
});
const profileInitial = computed(() => {
const name = profile.value?.user.displayName.trim() || currentUser.value?.displayName.trim() || '';
return name.charAt(0).toUpperCase() || '#';
@@ -176,6 +190,14 @@ const communityStats = computed(() => {
{ label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 }
];
});
const socialStats = computed(() => {
const social = profile.value?.social;
return [
{ label: t('pages.profile.followers'), value: social?.followerCount ?? 0 },
{ label: t('pages.profile.followingCount'), value: social?.followingCount ?? 0 },
{ label: t('pages.profile.friends'), value: social?.friendCount ?? 0 }
];
});
const filteredContributions = computed(() => {
const items = profile.value?.contributions ?? [];
if (contributionFilter.value === 'all') {
@@ -280,6 +302,7 @@ async function loadProfile() {
errorMessage.value = '';
passwordMessage.value = '';
passwordErrorMessage.value = '';
followErrorMessage.value = '';
referralSummaryMessage.value = '';
referralSummaryErrorMessage.value = '';
referralMessage.value = '';
@@ -339,6 +362,27 @@ async function loadProfile() {
}
}
async function toggleFollow() {
const target = profile.value?.user;
if (!target || !canFollowProfile.value || followBusy.value) {
return;
}
followErrorMessage.value = '';
followBusy.value = true;
try {
const relation = profile.value?.social.viewerRelation ?? 'none';
const response = relation === 'following' || relation === 'friends'
? await api.unfollowUser(target.id)
: await api.followUser(target.id);
profile.value = response.profile;
} catch (error) {
followErrorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.followFailed');
} finally {
followBusy.value = false;
}
}
async function saveProfile() {
message.value = '';
errorMessage.value = '';
@@ -680,6 +724,14 @@ onMounted(() => {
:tone="currentUser.emailVerified ? 'success' : 'warning'"
/>
<div v-if="canFollowProfile" class="profile-follow-actions">
<button class="ui-button ui-button--blue" type="button" :disabled="followBusy" @click="toggleFollow">
<Icon :icon="iconReferral" class="ui-icon" aria-hidden="true" />
{{ followButtonLabel }}
</button>
<StatusMessage v-if="followErrorMessage" variant="danger" :duration="0">{{ followErrorMessage }}</StatusMessage>
</div>
<dl class="profile-stat-strip">
<div v-for="item in headlineStats" :key="item.label">
<dt>{{ item.label }}</dt>
@@ -687,6 +739,13 @@ onMounted(() => {
</div>
</dl>
<dl class="profile-stat-strip profile-stat-strip--social">
<div v-for="item in socialStats" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
<div v-if="canShowAccount && referral" class="profile-referral-summary">
<div>
<span>{{ t('pages.profile.referralCode') }}</span>