From 579d09202074ae822de715632e0168ff35721cc2 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Mon, 4 May 2026 10:10:38 +0800 Subject: [PATCH] feat(life): add Life Post reaction users modal and API Add GET /api/life-posts/:id/reactions endpoint with pagination Add LifeReactionUsersModal to view and filter reaction users Make reaction summaries clickable in feeds, details, and profiles --- DESIGN.md | 6 +- backend/src/queries.ts | 140 ++++++++++++++ backend/src/server.ts | 16 ++ .../src/components/LifeReactionUsersModal.vue | 178 ++++++++++++++++++ frontend/src/services/api.ts | 27 +++ frontend/src/styles/main.css | 130 +++++++++++++ frontend/src/views/LifePostDetail.vue | 25 ++- frontend/src/views/LifeView.vue | 25 ++- frontend/src/views/UserProfileView.vue | 35 +++- system-wordings.ts | 12 ++ 10 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/LifeReactionUsersModal.vue diff --git a/DESIGN.md b/DESIGN.md index b2c295d..b12e8bf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -124,6 +124,7 @@ - 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容。 - 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。 - 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。 + - 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 来源查看。 - 公开用户摘要只包含 `id`、`displayName` 和公开展示需要的加入时间;不公开邮箱、角色、权限、Referral Code、邀请链接、session、token/hash 或内部审计 payload。 @@ -789,6 +790,7 @@ Life Post 可配置: - Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。 - 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 - Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 +- 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。 - 已注册并完成邮箱验证且拥有 `life.ratings.set` 权限的用户可以对 Rateable Life Post 设置或取消 1-5 星评分;非 Rateable Category 下的 Post 不显示评分控件,也不能通过 API 评分。 - Life Post 展示评分时只展示平均分、评分人数和当前用户自己的评分;不展示其他用户的评分明细。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 @@ -816,7 +818,8 @@ API 暴露边界: - Life Post Rating 只返回 `ratingAverage`、`ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。 - Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审;不返回内部错误、AI prompt、模型响应或 retry 细节。 - Life Comment 作者信息只返回 `id` 和 `displayName`。 -- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。 +- Life Post 列表和详情中的 Life Reaction 只返回按类型汇总的数量和当前用户自己的 Reaction,不内嵌其他用户明细。 +- Life Reaction 用户列表 API 只返回公开用户摘要 `id`、`displayName`、`reactionType` 和 `reactedAt`;不返回邮箱、角色、权限、token/hash、内部审计或其他用户隐私字段。 - Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount` 和 `commentPreview`,不内嵌完整评论列表。 - Life Post 详情 API 返回单条 Life Post,字段边界与列表项一致;评论字段仍只包含 `commentCount` 和少量 `commentPreview`,完整评论通过评论分页接口读取。 - Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌;普通访客只读取审核通过评论。 @@ -964,6 +967,7 @@ API 暴露边界: - `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/: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/life-posts`:分页读取该用户发布过且未删除的 Life Post。 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 4b67a37..77682ae 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -256,6 +256,21 @@ type EntityDiscussionCommentsPage = { type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; type LifeReactionCounts = Record; +type LifeReactionUser = { + user: { id: number; displayName: string }; + reactionType: LifeReactionType; + reactedAt: Date; +}; +type LifeReactionUsersPage = { + items: LifeReactionUser[]; + nextCursor: string | null; + hasMore: boolean; + total: number; +}; +type LifeReactionUserCursor = { + reactedAt: string; + userId: number; +}; type LifeCommentRow = { id: number; @@ -2760,6 +2775,34 @@ function encodeProfileCursor(cursor: LifePostCursor): string { return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); } +function decodeLifeReactionUserCursor(value: QueryValue): LifeReactionUserCursor | null { + const rawCursor = asString(value); + if (!rawCursor) { + return null; + } + + try { + const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial; + const reactedAt = typeof cursor.reactedAt === 'string' ? cursor.reactedAt : ''; + const userId = Number(cursor.userId); + + if (!reactedAt || Number.isNaN(new Date(reactedAt).getTime()) || !Number.isInteger(userId) || userId <= 0) { + throw validationError('server.validation.cursorInvalid'); + } + + return { reactedAt, userId }; + } catch (error) { + if (error instanceof Error && 'statusCode' in error) { + throw error; + } + throw validationError('server.validation.cursorInvalid'); + } +} + +function encodeLifeReactionUserCursor(cursor: LifeReactionUserCursor): string { + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); +} + function decodeUserCommentActivityCursor(value: QueryValue): UserCommentActivityCursor | null { const rawCursor = asString(value); if (!rawCursor) { @@ -3102,6 +3145,103 @@ async function lifeReactionsForPosts( return { countsByPost, myReactionsByPost }; } +export async function listLifePostReactionUsers( + postIdValue: number, + paramsQuery: QueryParams = {}, + userId: number | null = null, + canViewAll = false +): Promise { + const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid'); + const cursor = decodeLifeReactionUserCursor(paramsQuery.cursor); + const limit = cleanLifePostLimit(paramsQuery.limit); + const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType); + const postParams: unknown[] = [postId]; + const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL']; + addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll); + const exists = await queryOne<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM life_posts lp + WHERE ${postConditions.join(' AND ')} + ) AS exists + `, + postParams + ); + + if (exists?.exists !== true) { + return null; + } + + const params: unknown[] = [postId]; + const conditions = ['lpr.post_id = $1']; + if (reactionType) { + params.push(reactionType); + conditions.push(`lpr.reaction_type = $${params.length}`); + } + if (cursor) { + params.push(cursor.reactedAt, cursor.userId); + conditions.push(`(lpr.updated_at, lpr.user_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`); + } + + params.push(limit + 1); + const rows = await query<{ + userId: number; + displayName: string; + reactionType: LifeReactionType; + reactedAt: Date; + reactedAtCursor: string; + }>( + ` + SELECT + u.id AS "userId", + u.display_name AS "displayName", + lpr.reaction_type AS "reactionType", + lpr.updated_at AS "reactedAt", + lpr.updated_at::text AS "reactedAtCursor" + FROM life_post_reactions lpr + JOIN users u ON u.id = lpr.user_id + WHERE ${conditions.join(' AND ')} + ORDER BY lpr.updated_at DESC, lpr.user_id DESC + LIMIT $${params.length} + `, + params + ); + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + const totalParams: unknown[] = [postId]; + const totalConditions = ['post_id = $1']; + if (reactionType) { + totalParams.push(reactionType); + totalConditions.push(`reaction_type = $${totalParams.length}`); + } + const total = await queryOne<{ total: number }>( + ` + SELECT COUNT(*)::integer AS total + FROM life_post_reactions + WHERE ${totalConditions.join(' AND ')} + `, + totalParams + ); + + return { + items: items.map((item) => ({ + user: { id: item.userId, displayName: item.displayName }, + reactionType: item.reactionType, + reactedAt: item.reactedAt + })), + nextCursor: + hasMore && items.length > 0 + ? encodeLifeReactionUserCursor({ + reactedAt: items[items.length - 1].reactedAtCursor, + userId: items[items.length - 1].userId + }) + : null, + hasMore, + total: total?.total ?? 0 + }; +} + async function lifeRatingsForPosts(postIds: number[], userId: number | null): Promise> { const myRatingsByPost = new Map(); diff --git a/backend/src/server.ts b/backend/src/server.ts index 240ed48..4ea08bd 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -84,6 +84,7 @@ import { listLifeComments, listLanguages, listLifePosts, + listLifePostReactionUsers, listPokemon, listPokemonFetchOptions, listRecipes, @@ -1209,6 +1210,21 @@ app.get('/api/life-posts/:id', async (request, reply) => { return post ? post : notFound(reply, request); }); +app.get('/api/life-posts/:id/reactions', async (request, reply) => { + const { id } = request.params as { id: string }; + const user = await optionalUser(request); + const canViewAll = user + ? userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any') + : false; + const reactions = await listLifePostReactionUsers( + Number(id), + request.query as Record, + user?.id ?? null, + canViewAll + ); + return reactions ? reactions : notFound(reply, request); +}); + app.get('/api/life-posts/:postId/comments', async (request, reply) => { const { postId } = request.params as { postId: string }; const user = await optionalUser(request); diff --git a/frontend/src/components/LifeReactionUsersModal.vue b/frontend/src/components/LifeReactionUsersModal.vue new file mode 100644 index 0000000..49e5e47 --- /dev/null +++ b/frontend/src/components/LifeReactionUsersModal.vue @@ -0,0 +1,178 @@ + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5bead36..bb9dd9a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -404,6 +404,25 @@ export interface LifeCommentsPage { total: number; } +export interface LifeReactionUser { + user: UserSummary; + reactionType: LifeReactionType; + reactedAt: string; +} + +export interface LifeReactionUsersPage { + items: LifeReactionUser[]; + nextCursor: string | null; + hasMore: boolean; + total: number; +} + +export interface LifeReactionUsersParams { + cursor?: string | null; + limit?: number; + reactionType?: LifeReactionType; +} + export interface RecipeDetail extends Recipe { acquisition_methods: NamedEntity[]; editHistory: EditHistoryEntry[]; @@ -1040,6 +1059,14 @@ export const api = { setLifeReaction: (id: string | number, reactionType: LifeReactionType) => sendJson(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }), deleteLifeReaction: (id: string | number) => deleteAndGetJson(`/api/life-posts/${id}/reaction`), + lifeReactionUsers: (id: string | number, params: LifeReactionUsersParams = {}) => + getJson( + `/api/life-posts/${id}/reactions${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit, + reactionType: params.reactionType + })}` + ), setLifeRating: (id: string | number, rating: number) => sendJson(`/api/life-posts/${id}/rating`, 'PUT', { rating }), deleteLifeRating: (id: string | number) => deleteAndGetJson(`/api/life-posts/${id}/rating`), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 5e3ef37..325f686 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -2670,6 +2670,21 @@ button:disabled, color: var(--ink-soft); } +.life-reaction-summary--button { + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + text-align: left; +} + +.life-reaction-summary--button:hover .life-reaction-summary__item, +.life-reaction-summary--button:focus-visible .life-reaction-summary__item { + border-color: color-mix(in srgb, var(--pokemon-blue) 45%, var(--line)); + background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft)); + color: var(--pokemon-blue-deep); +} + .life-action-tooltip { position: absolute; z-index: 30; @@ -2868,6 +2883,101 @@ button:disabled, margin: 0; } +.life-reaction-users-modal { + display: grid; + gap: 14px; +} + +.life-reaction-users-modal__count { + margin: 0; + color: var(--muted); + font-size: 14px; + font-weight: 850; +} + +.life-reaction-user-list { + display: grid; + gap: 10px; +} + +.life-reaction-user { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.life-reaction-user__avatar { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border: 2px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--pokemon-blue-deep); + font-family: var(--font-display); + font-weight: 950; + text-decoration: none; +} + +.life-reaction-user__avatar:hover { + border-color: color-mix(in srgb, var(--pokemon-blue) 45%, var(--line)); + background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface)); +} + +.life-reaction-user__copy { + display: grid; + gap: 3px; + min-width: 0; +} + +.life-reaction-user__copy > span { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.life-reaction-user__copy .ui-icon { + width: 18px; + height: 18px; + color: var(--pokemon-blue); +} + +.life-reaction-users-empty { + display: grid; + justify-items: center; + gap: 8px; + padding: 22px 14px; + border: 1px dashed var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); + text-align: center; +} + +.life-reaction-users-empty h3 { + margin: 0; + color: var(--ink-soft); + font-family: var(--font-display); + font-size: 20px; + font-weight: 950; +} + +.life-reaction-users-empty__icon { + width: 34px; + height: 34px; + color: var(--pokemon-blue); +} + .life-empty { width: min(100%, 680px); justify-self: center; @@ -5870,6 +5980,26 @@ button:disabled, gap: 6px; } +.profile-reaction-open-button { + min-height: 32px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-weight: inherit; + text-align: left; +} + +.profile-reaction-open-button:hover { + color: var(--pokemon-blue-deep); + text-decoration: underline; + text-underline-offset: 3px; +} + .profile-feed-card__detail-link, .profile-post-preview__detail { display: inline-flex; diff --git a/frontend/src/views/LifePostDetail.vue b/frontend/src/views/LifePostDetail.vue index 23070dd..3071598 100644 --- a/frontend/src/views/LifePostDetail.vue +++ b/frontend/src/views/LifePostDetail.vue @@ -4,6 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import LifeRatingControl from '../components/LifeRatingControl.vue'; +import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import StatusBadge from '../components/StatusBadge.vue'; @@ -61,6 +62,7 @@ const ratingBusyPostId = ref(null); const ratingErrors = ref>({}); const moderationBusyPostId = ref(null); const moderationErrors = ref>({}); +const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null); const lifeCommentPageSize = 20; const commentMaxLength = 1000; let removeAuthListener: (() => void) | null = null; @@ -239,6 +241,14 @@ function reactionCountLabel(currentPost: LifePost, type: LifeReactionType) { }); } +function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) { + reactionUsersModal.value = { postId, reactionType }; +} + +function closeReactionUsersModal() { + reactionUsersModal.value = null; +} + function moderationLabel(status: AiModerationStatus) { const labels: Record = { unreviewed: t('pages.life.moderationUnreviewed'), @@ -577,6 +587,13 @@ onUnmounted(() => { {{ loadError }} + +
-
-
+
- +