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:
12
DESIGN.md
12
DESIGN.md
@@ -127,6 +127,10 @@
|
||||
- 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容。
|
||||
- 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。
|
||||
- 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。
|
||||
- 用户可 Follow 其他用户;Follow 是单向关系,双方互相 Follow 时在展示层视为 Friends。
|
||||
- Friend 不单独存储为独立关系,始终由双向 Follow 派生,避免双写不一致。
|
||||
- 公开 Profile 展示 Followers、Following 和 Friends 数量;登录用户查看其他用户 Profile 时可看到自己与对方的关系状态:未关注、已关注、被对方关注或 Friends。
|
||||
- 登录且邮箱已验证并拥有 `users.follow` 权限的用户可以 Follow / Unfollow 其他用户;用户不能 Follow 自己。
|
||||
- Profile 的 Feeds 和 Reactions 中可从 Life Post 的 Reaction 汇总或 Reaction 活动打开公开 Reaction 用户列表 Modal。
|
||||
- Profile 使用 Tabs 组织:Feeds、Contributions、Reactions、Comments;仅自己的 `/profile` 额外展示 Account。
|
||||
- Contributions、Reactions、Comments 在对应 Tab 内提供二级分类:Contributions 可按主要内容类型或配置类查看,Reactions 可按 reaction 类型查看,Comments 可按 Life / Wiki discussion 来源查看。
|
||||
@@ -256,6 +260,7 @@
|
||||
- 通知和审核状态实时更新可以走 WebSocket;WebSocket 连接使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
||||
- AI 审核从 `reviewing` 变更为 `approved`、`rejected` 或 `failed` 后,前端当前可见的对应 Life Post、Life Comment 或实体讨论评论状态、语言区和可展示的审核原因详情应通过 WebSocket 直接更新,不要求用户刷新页面。
|
||||
- 通知范围:
|
||||
- 用户被别人 Follow 时,通知被 Follow 的用户;同一用户重复 Follow 同一目标时合并更新同一通知。
|
||||
- Life Post 收到审核通过后的顶层评论时,通知 Life Post 作者。
|
||||
- Life Comment 收到审核通过后的回复时,通知父评论作者。
|
||||
- 实体讨论评论收到审核通过后的回复时,通知父评论作者。
|
||||
@@ -277,6 +282,7 @@
|
||||
- `updatedAt`
|
||||
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
|
||||
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
|
||||
- Follow 对象发布 Life Post 的动态属于 Following Feed,不进入 Notifications,不产生未读数量,也不需要标记已读。
|
||||
|
||||
## 滥用防护与限流
|
||||
|
||||
@@ -834,6 +840,7 @@ Life Post 可配置:
|
||||
- Feed 支持按 Game Version 筛选;All versions 表示不过滤版本。
|
||||
- Feed 支持 Rateable 筛选;All 表示不过滤,Rateable only 只展示可评分 Category 下的 Post。
|
||||
- Feed 支持排序:Latest 默认按创建时间倒序;Oldest 按创建时间正序;Top rated 按平均评分倒序,同分时按创建时间倒序。
|
||||
- 登录用户可切换 All Feed 和 Following Feed;Following Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post,并继续支持 Life Category、语言、Game Version、Rateable 和排序筛选。
|
||||
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
||||
- 当前没有图片上传、转发或置顶。
|
||||
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
||||
@@ -1003,13 +1010,16 @@ API 暴露边界:
|
||||
- `GET /api/recipes`
|
||||
- `GET /api/recipes/:id`
|
||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
||||
- `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。
|
||||
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
|
||||
- `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit` 和 `reactionType` 筛选。
|
||||
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
|
||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。
|
||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
||||
- `PUT /api/users/:id/follow`:需要 `users.follow`;Follow 指定用户并返回更新后的公开 Profile。
|
||||
- `DELETE /api/users/:id/follow`:需要 `users.follow`;Unfollow 指定用户并返回更新后的公开 Profile。
|
||||
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`、`ancient-artifacts`。
|
||||
|
||||
认证 API:
|
||||
|
||||
@@ -94,6 +94,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx
|
||||
CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx
|
||||
ON users(referred_by_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_follows (
|
||||
follower_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
followed_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (follower_user_id, followed_user_id),
|
||||
CHECK (follower_user_id <> followed_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx
|
||||
ON user_follows(followed_user_id, created_at DESC, follower_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS environments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
@@ -290,6 +301,7 @@ VALUES
|
||||
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
|
||||
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
|
||||
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
|
||||
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
|
||||
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
||||
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
|
||||
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true)
|
||||
@@ -381,6 +393,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'life.comments.delete-any',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete',
|
||||
'discussions.comments.delete-any'
|
||||
@@ -449,6 +462,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'life.comments.delete',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete'
|
||||
])
|
||||
@@ -496,6 +510,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'life.comments.delete',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete'
|
||||
])
|
||||
@@ -514,6 +529,13 @@ JOIN permissions p ON p.key = 'life.ratings.set'
|
||||
WHERE r.key IN ('admin', 'editor', 'member')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.key = 'users.follow'
|
||||
WHERE r.key IN ('admin', 'editor', 'member')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
WITH first_owner_user AS (
|
||||
SELECT u.id
|
||||
FROM users u
|
||||
@@ -1231,10 +1253,12 @@ CREATE TABLE IF NOT EXISTS notifications (
|
||||
'life_comment_reply',
|
||||
'discussion_comment_reply',
|
||||
'life_post_reaction',
|
||||
'user_follow',
|
||||
'moderation_result'
|
||||
)
|
||||
),
|
||||
life_post_id integer REFERENCES life_posts(id) ON DELETE CASCADE,
|
||||
profile_user_id integer REFERENCES users(id) ON DELETE CASCADE,
|
||||
life_comment_id integer REFERENCES life_post_comments(id) ON DELETE CASCADE,
|
||||
parent_life_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
|
||||
discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
||||
@@ -1274,6 +1298,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_reaction_unique_idx
|
||||
ON notifications(recipient_user_id, actor_user_id, life_post_id)
|
||||
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE notifications
|
||||
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx
|
||||
ON notifications(recipient_user_id, actor_user_id, profile_user_id)
|
||||
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_ws_tickets (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -1289,6 +1320,24 @@ CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
|
||||
ALTER TABLE notifications
|
||||
ADD COLUMN IF NOT EXISTS moderation_reason text;
|
||||
|
||||
ALTER TABLE notifications
|
||||
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE notifications
|
||||
DROP CONSTRAINT IF EXISTS notifications_type_check;
|
||||
|
||||
ALTER TABLE notifications
|
||||
ADD CONSTRAINT notifications_type_check CHECK (
|
||||
type IN (
|
||||
'life_post_comment',
|
||||
'life_comment_reply',
|
||||
'discussion_comment_reply',
|
||||
'life_post_reaction',
|
||||
'user_follow',
|
||||
'moderation_result'
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE life_tags
|
||||
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ type NotificationType =
|
||||
| 'life_comment_reply'
|
||||
| 'discussion_comment_reply'
|
||||
| 'life_post_reaction'
|
||||
| 'user_follow'
|
||||
| 'moderation_result';
|
||||
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
||||
type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
||||
type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'profile-user';
|
||||
type ModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
||||
|
||||
type NotificationCursor = {
|
||||
@@ -35,6 +36,7 @@ type NotificationRow = {
|
||||
actor: NotificationActor | null;
|
||||
type: NotificationType;
|
||||
lifePostId: number | null;
|
||||
profileUserId: number | null;
|
||||
lifeCommentId: number | null;
|
||||
parentLifeCommentId: number | null;
|
||||
discussionCommentId: number | null;
|
||||
@@ -55,6 +57,7 @@ export type NotificationTarget = {
|
||||
id: number;
|
||||
path: string;
|
||||
lifePostId: number | null;
|
||||
profileUserId: number | null;
|
||||
lifeCommentId: number | null;
|
||||
discussionCommentId: number | null;
|
||||
entityType: DiscussionEntityType | null;
|
||||
@@ -147,6 +150,7 @@ function notificationProjection(): string {
|
||||
n.recipient_user_id AS "recipientUserId",
|
||||
n.type,
|
||||
n.life_post_id AS "lifePostId",
|
||||
n.profile_user_id AS "profileUserId",
|
||||
n.life_comment_id AS "lifeCommentId",
|
||||
n.parent_life_comment_id AS "parentLifeCommentId",
|
||||
n.discussion_comment_id AS "discussionCommentId",
|
||||
@@ -178,6 +182,9 @@ function discussionEntityPath(entityType: DiscussionEntityType | null, entityId:
|
||||
}
|
||||
|
||||
function notificationTargetType(row: NotificationRow): NotificationTargetType {
|
||||
if (row.profileUserId !== null) {
|
||||
return 'profile-user';
|
||||
}
|
||||
if (row.discussionCommentId !== null) {
|
||||
return 'discussion-comment';
|
||||
}
|
||||
@@ -188,6 +195,9 @@ function notificationTargetType(row: NotificationRow): NotificationTargetType {
|
||||
}
|
||||
|
||||
function notificationPath(row: NotificationRow): string {
|
||||
if (row.profileUserId !== null) {
|
||||
return `/profile/${row.profileUserId}`;
|
||||
}
|
||||
if (row.lifePostId !== null) {
|
||||
return `/life/${row.lifePostId}`;
|
||||
}
|
||||
@@ -198,7 +208,9 @@ function notificationPath(row: NotificationRow): string {
|
||||
function toNotificationItem(row: NotificationRow): NotificationItem {
|
||||
const targetType = notificationTargetType(row);
|
||||
const targetId =
|
||||
targetType === 'discussion-comment'
|
||||
targetType === 'profile-user'
|
||||
? row.profileUserId
|
||||
: targetType === 'discussion-comment'
|
||||
? row.discussionCommentId
|
||||
: targetType === 'life-comment'
|
||||
? row.lifeCommentId
|
||||
@@ -213,6 +225,7 @@ function toNotificationItem(row: NotificationRow): NotificationItem {
|
||||
id: targetId ?? 0,
|
||||
path: notificationPath(row),
|
||||
lifePostId: row.lifePostId,
|
||||
profileUserId: row.profileUserId,
|
||||
lifeCommentId: row.lifeCommentId,
|
||||
discussionCommentId: row.discussionCommentId,
|
||||
entityType: row.entityType,
|
||||
@@ -458,6 +471,43 @@ export async function createLifePostReactionNotification(postId: number, actorUs
|
||||
await publishInsertedNotification(row);
|
||||
}
|
||||
|
||||
export async function createUserFollowNotification(actorUserId: number, followedUserId: number): Promise<void> {
|
||||
const row = await queryOne<{ id: number; recipientUserId: number }>(
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
recipient_user_id,
|
||||
actor_user_id,
|
||||
type,
|
||||
profile_user_id,
|
||||
read_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
followed_user.id,
|
||||
actor_user.id,
|
||||
'user_follow',
|
||||
actor_user.id,
|
||||
NULL,
|
||||
now(),
|
||||
now()
|
||||
FROM users actor_user
|
||||
JOIN users followed_user ON followed_user.id = $2
|
||||
WHERE actor_user.id = $1
|
||||
AND actor_user.id <> followed_user.id
|
||||
ON CONFLICT (recipient_user_id, actor_user_id, profile_user_id)
|
||||
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL
|
||||
DO UPDATE SET read_at = NULL,
|
||||
created_at = now(),
|
||||
updated_at = now()
|
||||
RETURNING id, recipient_user_id AS "recipientUserId"
|
||||
`,
|
||||
[actorUserId, followedUserId]
|
||||
);
|
||||
|
||||
await publishInsertedNotification(row);
|
||||
}
|
||||
|
||||
export async function createApprovedCommentNotification(target: {
|
||||
type: ModerationTargetType;
|
||||
id: number;
|
||||
@@ -613,6 +663,7 @@ export async function createModerationResultNotification(
|
||||
id: row.lifePostId,
|
||||
path: `/life/${row.lifePostId}`,
|
||||
lifePostId: row.lifePostId,
|
||||
profileUserId: null,
|
||||
lifeCommentId: null,
|
||||
discussionCommentId: null,
|
||||
entityType: null,
|
||||
@@ -688,6 +739,7 @@ export async function createModerationResultNotification(
|
||||
id: row.lifeCommentId,
|
||||
path: `/life/${row.lifePostId}`,
|
||||
lifePostId: row.lifePostId,
|
||||
profileUserId: null,
|
||||
lifeCommentId: row.lifeCommentId,
|
||||
discussionCommentId: null,
|
||||
entityType: null,
|
||||
@@ -764,6 +816,7 @@ export async function createModerationResultNotification(
|
||||
id: row.discussionCommentId,
|
||||
path: discussionEntityPath(row.entityType, row.entityId) ?? '/',
|
||||
lifePostId: null,
|
||||
profileUserId: null,
|
||||
lifeCommentId: null,
|
||||
discussionCommentId: row.discussionCommentId,
|
||||
entityType: row.entityType,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
requestAiModerationReview,
|
||||
type AiModerationStatus
|
||||
} from './aiModeration.ts';
|
||||
import { createLifePostReactionNotification } from './notifications.ts';
|
||||
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
|
||||
|
||||
type QueryValue = string | string[] | undefined;
|
||||
|
||||
@@ -353,6 +353,7 @@ type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
||||
|
||||
type LifePostFilters = {
|
||||
authorId?: number;
|
||||
followedByUserId?: number;
|
||||
};
|
||||
|
||||
type LifePostsPage = {
|
||||
@@ -395,9 +396,19 @@ type PublicProfileContribution = {
|
||||
lastContributedAt: Date | null;
|
||||
};
|
||||
|
||||
type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends';
|
||||
|
||||
type PublicProfileSocial = {
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
friendCount: number;
|
||||
viewerRelation: PublicProfileViewerRelation;
|
||||
};
|
||||
|
||||
type PublicUserProfile = {
|
||||
user: PublicProfileUser;
|
||||
stats: PublicProfileStats;
|
||||
social: PublicProfileSocial;
|
||||
contributions: PublicProfileContribution[];
|
||||
};
|
||||
|
||||
@@ -3535,6 +3546,18 @@ async function listLifePostsWithFilters(
|
||||
conditions.push(`lp.created_by_user_id = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.followedByUserId !== undefined) {
|
||||
params.push(filters.followedByUserId);
|
||||
conditions.push(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM user_follows uf
|
||||
WHERE uf.follower_user_id = $${params.length}
|
||||
AND uf.followed_user_id = lp.created_by_user_id
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
||||
addModerationLanguageCondition(conditions, params, 'lp', languageCode);
|
||||
|
||||
@@ -3616,6 +3639,15 @@ export async function listLifePosts(
|
||||
return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
|
||||
}
|
||||
|
||||
export async function listFollowingLifePosts(
|
||||
userId: number,
|
||||
paramsQuery: QueryParams = {},
|
||||
locale = defaultLocale,
|
||||
canViewAll = false
|
||||
): Promise<LifePostsPage> {
|
||||
return listLifePostsWithFilters(paramsQuery, userId, locale, { followedByUserId: userId }, canViewAll);
|
||||
}
|
||||
|
||||
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
|
||||
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
|
||||
return queryOne<PublicProfileUser>(
|
||||
@@ -3635,7 +3667,68 @@ function publicContributionType(entityType: string): string {
|
||||
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
|
||||
}
|
||||
|
||||
export async function getPublicUserProfile(userIdValue: number): Promise<PublicUserProfile | null> {
|
||||
async function getPublicProfileSocial(userId: number, viewerUserId: number | null): Promise<PublicProfileSocial> {
|
||||
const social = await queryOne<
|
||||
Omit<PublicProfileSocial, 'viewerRelation'> & {
|
||||
viewerFollows: boolean;
|
||||
targetFollowsViewer: boolean;
|
||||
}
|
||||
>(
|
||||
`
|
||||
SELECT
|
||||
COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE followed_user_id = $1), 0) AS "followerCount",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE follower_user_id = $1), 0) AS "followingCount",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM user_follows outgoing
|
||||
WHERE outgoing.follower_user_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_follows incoming
|
||||
WHERE incoming.follower_user_id = outgoing.followed_user_id
|
||||
AND incoming.followed_user_id = $1
|
||||
)
|
||||
), 0) AS "friendCount",
|
||||
CASE
|
||||
WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
|
||||
ELSE EXISTS (
|
||||
SELECT 1
|
||||
FROM user_follows
|
||||
WHERE follower_user_id = $2::integer
|
||||
AND followed_user_id = $1
|
||||
)
|
||||
END AS "viewerFollows",
|
||||
CASE
|
||||
WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
|
||||
ELSE EXISTS (
|
||||
SELECT 1
|
||||
FROM user_follows
|
||||
WHERE follower_user_id = $1
|
||||
AND followed_user_id = $2::integer
|
||||
)
|
||||
END AS "targetFollowsViewer"
|
||||
`,
|
||||
[userId, viewerUserId]
|
||||
);
|
||||
|
||||
const viewerRelation =
|
||||
social?.viewerFollows && social.targetFollowsViewer
|
||||
? 'friends'
|
||||
: social?.viewerFollows
|
||||
? 'following'
|
||||
: social?.targetFollowsViewer
|
||||
? 'followed-by'
|
||||
: 'none';
|
||||
|
||||
return {
|
||||
followerCount: social?.followerCount ?? 0,
|
||||
followingCount: social?.followingCount ?? 0,
|
||||
friendCount: social?.friendCount ?? 0,
|
||||
viewerRelation
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicUserProfile(userIdValue: number, viewerUserId: number | null = null): Promise<PublicUserProfile | null> {
|
||||
const user = await getPublicProfileUser(userIdValue);
|
||||
if (!user) {
|
||||
return null;
|
||||
@@ -3702,6 +3795,8 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
||||
[user.id]
|
||||
);
|
||||
|
||||
const social = await getPublicProfileSocial(user.id, viewerUserId);
|
||||
|
||||
return {
|
||||
user,
|
||||
stats: stats ?? {
|
||||
@@ -3715,6 +3810,7 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
||||
lifeReactions: 0,
|
||||
discussionComments: 0
|
||||
},
|
||||
social,
|
||||
contributions: contributions.map((item) => ({
|
||||
...item,
|
||||
contentType: publicContributionType(item.contentType)
|
||||
@@ -3722,6 +3818,57 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
||||
};
|
||||
}
|
||||
|
||||
export async function followUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
|
||||
const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
|
||||
if (followerUserId === followedUserId) {
|
||||
throw validationError('server.validation.cannotFollowSelf');
|
||||
}
|
||||
|
||||
const followedUser = await getPublicProfileUser(followedUserId);
|
||||
if (!followedUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inserted = await queryOne<{ inserted: boolean }>(
|
||||
`
|
||||
INSERT INTO user_follows (follower_user_id, followed_user_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (follower_user_id, followed_user_id) DO NOTHING
|
||||
RETURNING true AS inserted
|
||||
`,
|
||||
[followerUserId, followedUser.id]
|
||||
);
|
||||
|
||||
if (inserted?.inserted === true) {
|
||||
await createUserFollowNotification(followerUserId, followedUser.id);
|
||||
}
|
||||
|
||||
return getPublicUserProfile(followedUser.id, followerUserId);
|
||||
}
|
||||
|
||||
export async function unfollowUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
|
||||
const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
|
||||
if (followerUserId === followedUserId) {
|
||||
throw validationError('server.validation.cannotFollowSelf');
|
||||
}
|
||||
|
||||
const followedUser = await getPublicProfileUser(followedUserId);
|
||||
if (!followedUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
DELETE FROM user_follows
|
||||
WHERE follower_user_id = $1
|
||||
AND followed_user_id = $2
|
||||
`,
|
||||
[followerUserId, followedUser.id]
|
||||
);
|
||||
|
||||
return getPublicUserProfile(followedUser.id, followerUserId);
|
||||
}
|
||||
|
||||
export async function listUserLifePosts(
|
||||
userIdValue: number,
|
||||
paramsQuery: QueryParams = {},
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
exportAdminData,
|
||||
fetchPokemonData,
|
||||
fetchPokemonImageOptions,
|
||||
followUser,
|
||||
getAdminDataToolsSummary,
|
||||
getAncientArtifact,
|
||||
getHabitat,
|
||||
@@ -81,6 +82,7 @@ import {
|
||||
listConfig,
|
||||
listDailyChecklistItems,
|
||||
listHabitats,
|
||||
listFollowingLifePosts,
|
||||
listItems,
|
||||
listLifeComments,
|
||||
listLanguages,
|
||||
@@ -115,6 +117,7 @@ import {
|
||||
updateLifePost,
|
||||
updatePokemon,
|
||||
updateRecipe,
|
||||
unfollowUser,
|
||||
wipeAdminData
|
||||
} from './queries.ts';
|
||||
import {
|
||||
@@ -1184,7 +1187,30 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque
|
||||
|
||||
app.get('/api/users/:id/profile', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const profile = await getPublicUserProfile(Number(id));
|
||||
const user = await optionalUser(request);
|
||||
const profile = await getPublicUserProfile(Number(id), user?.id ?? null);
|
||||
return profile ? { profile } : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.put('/api/users/:id/follow', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction');
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = request.params as { id: string };
|
||||
const profile = await followUser(user.id, Number(id));
|
||||
return profile ? { profile } : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id/follow', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction');
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = request.params as { id: string };
|
||||
const profile = await unfollowUser(user.id, Number(id));
|
||||
return profile ? { profile } : notFound(reply, request);
|
||||
});
|
||||
|
||||
@@ -1239,6 +1265,20 @@ app.get('/api/life-posts', async (request) => {
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/life-posts/following', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const canViewAll = userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any');
|
||||
return listFollowingLifePosts(
|
||||
user.id,
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
requestLocale(request),
|
||||
canViewAll
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/life-posts/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const user = await optionalUser(request);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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')" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -106,10 +106,12 @@ export const systemWordingMessages = {
|
||||
targetLifePost: 'Life post',
|
||||
targetLifeComment: 'Life comment',
|
||||
targetDiscussionComment: 'discussion comment',
|
||||
targetProfile: 'profile',
|
||||
lifePostComment: '{actor} commented on your Life post',
|
||||
lifeCommentReply: '{actor} replied to your Life comment',
|
||||
discussionCommentReply: '{actor} replied to your discussion comment',
|
||||
lifePostReaction: '{actor} reacted {reaction} to your Life post',
|
||||
userFollow: '{actor} followed you',
|
||||
moderationApproved: 'Your {target} passed review',
|
||||
moderationRejected: 'Your {target} did not pass review',
|
||||
moderationFailed: 'Review failed for your {target}'
|
||||
@@ -504,6 +506,14 @@ export const systemWordingMessages = {
|
||||
passwordSaved: 'Password updated',
|
||||
passwordSaveFailed: 'Password update failed',
|
||||
savePassword: 'Save password',
|
||||
follow: 'Follow',
|
||||
followBack: 'Follow back',
|
||||
following: 'Following',
|
||||
friend: 'Friend',
|
||||
followers: 'Followers',
|
||||
followingCount: 'Following',
|
||||
friends: 'Friends',
|
||||
followFailed: 'Follow action failed',
|
||||
joinedAt: 'Joined {date}',
|
||||
lifePosts: 'Life posts',
|
||||
lifeComments: 'Life comments',
|
||||
@@ -848,6 +858,9 @@ export const systemWordingMessages = {
|
||||
languages: 'Languages',
|
||||
allLanguages: 'All languages',
|
||||
allCategories: 'All',
|
||||
feedScope: 'Feed scope',
|
||||
allFeed: 'All feed',
|
||||
followingFeed: 'Following',
|
||||
allVersions: 'All versions',
|
||||
versionFilter: 'Version',
|
||||
ratingFilter: 'Rating',
|
||||
@@ -1197,6 +1210,7 @@ export const systemWordingMessages = {
|
||||
invalidResetToken: 'The password reset link is invalid or expired',
|
||||
currentPasswordInvalid: 'Current password is incorrect',
|
||||
invalidReferralCode: 'Referral code is invalid',
|
||||
cannotFollowSelf: 'You cannot follow yourself',
|
||||
emailDeliveryUnavailable: 'Email delivery is temporarily unavailable. Please try again later.'
|
||||
},
|
||||
validation: {
|
||||
@@ -1411,10 +1425,12 @@ export const systemWordingMessages = {
|
||||
targetLifePost: 'Life 动态',
|
||||
targetLifeComment: 'Life 评论',
|
||||
targetDiscussionComment: '讨论评论',
|
||||
targetProfile: '个人主页',
|
||||
lifePostComment: '{actor} 评论了你的 Life 动态',
|
||||
lifeCommentReply: '{actor} 回复了你的 Life 评论',
|
||||
discussionCommentReply: '{actor} 回复了你的讨论评论',
|
||||
lifePostReaction: '{actor} 用 {reaction} Reaction 了你的 Life 动态',
|
||||
userFollow: '{actor} 关注了你',
|
||||
moderationApproved: '你的{target}已审核通过',
|
||||
moderationRejected: '你的{target}未通过审核',
|
||||
moderationFailed: '你的{target}审核失败'
|
||||
@@ -1783,6 +1799,14 @@ export const systemWordingMessages = {
|
||||
passwordSaved: '密码已更新',
|
||||
passwordSaveFailed: '密码更新失败',
|
||||
savePassword: '保存密码',
|
||||
follow: '关注',
|
||||
followBack: '回关',
|
||||
following: '已关注',
|
||||
friend: '好友',
|
||||
followers: '粉丝',
|
||||
followingCount: '关注',
|
||||
friends: '好友',
|
||||
followFailed: '关注操作失败',
|
||||
joinedAt: '加入于 {date}',
|
||||
lifePosts: 'Life 动态',
|
||||
lifeComments: 'Life 评论',
|
||||
@@ -2127,6 +2151,9 @@ export const systemWordingMessages = {
|
||||
languages: '语言区',
|
||||
allLanguages: '全部语言',
|
||||
allCategories: '全部',
|
||||
feedScope: '动态范围',
|
||||
allFeed: '全部动态',
|
||||
followingFeed: '关注动态',
|
||||
allVersions: '全部版本',
|
||||
versionFilter: '版本',
|
||||
ratingFilter: '评分',
|
||||
@@ -2476,6 +2503,7 @@ export const systemWordingMessages = {
|
||||
invalidResetToken: '密码重置链接无效或已过期',
|
||||
currentPasswordInvalid: '当前密码不正确',
|
||||
invalidReferralCode: '邀请码无效',
|
||||
cannotFollowSelf: '不能关注自己',
|
||||
emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。'
|
||||
},
|
||||
validation: {
|
||||
|
||||
Reference in New Issue
Block a user