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
This commit is contained in:
2026-05-04 10:10:38 +08:00
parent 7ff7e18b94
commit 579d092020
10 changed files with 583 additions and 11 deletions

View File

@@ -124,6 +124,7 @@
- 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容。 - 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容。
- 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。 - 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。
- 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。 - 公开 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。 - Profile 使用 Tabs 组织Feeds、Contributions、Reactions、Comments仅自己的 `/profile` 额外展示 Account。
- Contributions、Reactions、Comments 在对应 Tab 内提供二级分类Contributions 可按主要内容类型或配置类查看Reactions 可按 reaction 类型查看Comments 可按 Life / Wiki discussion 来源查看。 - Contributions、Reactions、Comments 在对应 Tab 内提供二级分类Contributions 可按主要内容类型或配置类查看Reactions 可按 reaction 类型查看Comments 可按 Life / Wiki discussion 来源查看。
- 公开用户摘要只包含 `id``displayName` 和公开展示需要的加入时间不公开邮箱、角色、权限、Referral Code、邀请链接、session、token/hash 或内部审计 payload。 - 公开用户摘要只包含 `id``displayName` 和公开展示需要的加入时间不公开邮箱、角色、权限、Referral Code、邀请链接、session、token/hash 或内部审计 payload。
@@ -789,6 +790,7 @@ Life Post 可配置:
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。 - Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like` - 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - 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.ratings.set` 权限的用户可以对 Rateable Life Post 设置或取消 1-5 星评分;非 Rateable Category 下的 Post 不显示评分控件,也不能通过 API 评分。
- Life Post 展示评分时只展示平均分、评分人数和当前用户自己的评分;不展示其他用户的评分明细。 - Life Post 展示评分时只展示平均分、评分人数和当前用户自己的评分;不展示其他用户的评分明细。
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
@@ -816,7 +818,8 @@ API 暴露边界:
- Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。 - Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
- Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审不返回内部错误、AI prompt、模型响应或 retry 细节。 - Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审不返回内部错误、AI prompt、模型响应或 retry 细节。
- Life Comment 作者信息只返回 `id``displayName` - 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 返回分页结果:`items``nextCursor``hasMore``cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount``commentPreview`,不内嵌完整评论列表。
- Life Post 详情 API 返回单条 Life Post字段边界与列表项一致评论字段仍只包含 `commentCount` 和少量 `commentPreview`,完整评论通过评论分页接口读取。 - Life Post 详情 API 返回单条 Life Post字段边界与列表项一致评论字段仍只包含 `commentCount` 和少量 `commentPreview`,完整评论通过评论分页接口读取。
- Life Comment 列表 API 返回分页结果:`items``nextCursor``hasMore``total``cursor` 是不透明分页令牌;普通访客只读取审核通过评论。 - Life Comment 列表 API 返回分页结果:`items``nextCursor``hasMore``total``cursor` 是不透明分页令牌;普通访客只读取审核通过评论。
@@ -964,6 +967,7 @@ API 暴露边界:
- `GET /api/recipes/:id` - `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`:支持 `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`:读取单条 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/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。 - `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。 - `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。

View File

@@ -256,6 +256,21 @@ type EntityDiscussionCommentsPage = {
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
type LifeReactionCounts = Record<LifeReactionType, number>; type LifeReactionCounts = Record<LifeReactionType, number>;
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 = { type LifeCommentRow = {
id: number; id: number;
@@ -2760,6 +2775,34 @@ function encodeProfileCursor(cursor: LifePostCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); 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<LifeReactionUserCursor>;
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 { function decodeUserCommentActivityCursor(value: QueryValue): UserCommentActivityCursor | null {
const rawCursor = asString(value); const rawCursor = asString(value);
if (!rawCursor) { if (!rawCursor) {
@@ -3102,6 +3145,103 @@ async function lifeReactionsForPosts(
return { countsByPost, myReactionsByPost }; return { countsByPost, myReactionsByPost };
} }
export async function listLifePostReactionUsers(
postIdValue: number,
paramsQuery: QueryParams = {},
userId: number | null = null,
canViewAll = false
): Promise<LifeReactionUsersPage | null> {
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<Map<number, number>> { async function lifeRatingsForPosts(postIds: number[], userId: number | null): Promise<Map<number, number>> {
const myRatingsByPost = new Map<number, number>(); const myRatingsByPost = new Map<number, number>();

View File

@@ -84,6 +84,7 @@ import {
listLifeComments, listLifeComments,
listLanguages, listLanguages,
listLifePosts, listLifePosts,
listLifePostReactionUsers,
listPokemon, listPokemon,
listPokemonFetchOptions, listPokemonFetchOptions,
listRecipes, listRecipes,
@@ -1209,6 +1210,21 @@ app.get('/api/life-posts/:id', async (request, reply) => {
return post ? post : notFound(reply, request); 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<string, string | string[] | undefined>,
user?.id ?? null,
canViewAll
);
return reactions ? reactions : notFound(reply, request);
});
app.get('/api/life-posts/:postId/comments', async (request, reply) => { app.get('/api/life-posts/:postId/comments', async (request, reply) => {
const { postId } = request.params as { postId: string }; const { postId } = request.params as { postId: string };
const user = await optionalUser(request); const user = await optionalUser(request);

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
iconReactionFun,
iconReactionHelpful,
iconReactionLike,
iconReactionThanks
} from '../icons';
import {
api,
type LifeReactionType,
type LifeReactionUser
} from '../services/api';
import Modal from './Modal.vue';
import Skeleton from './Skeleton.vue';
import Tabs, { type TabOption } from './Tabs.vue';
type ReactionFilter = LifeReactionType | 'all';
const props = defineProps<{
postId: number;
initialReactionType?: LifeReactionType | null;
}>();
const emit = defineEmits<{
close: [];
}>();
const { locale, t } = useI18n();
const reactionUsers = ref<LifeReactionUser[]>([]);
const nextCursor = ref<string | null>(null);
const hasMore = ref(false);
const total = ref(0);
const loading = ref(false);
const loadingMore = ref(false);
const loadError = ref('');
const activeReactionType = ref<ReactionFilter>(props.initialReactionType ?? 'all');
const pageSize = 20;
const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
{ type: 'fun', icon: iconReactionFun, labelKey: 'pages.life.reactionFun' },
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
const reactionTabs = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allReactions') },
...reactionOptions.map((option) => ({ value: option.type, label: reactionLabel(option.type) }))
]);
function reactionLabel(type: LifeReactionType) {
return t(reactionOptions.find((option) => option.type === type)?.labelKey ?? 'pages.life.react');
}
function reactionIcon(type: LifeReactionType) {
return reactionOptions.find((option) => option.type === type)?.icon ?? iconReactionLike;
}
function selectedReactionType() {
return activeReactionType.value === 'all' ? undefined : activeReactionType.value;
}
function formatReactedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
async function loadReactionUsers(reset = false) {
if (loading.value || loadingMore.value || (!reset && !hasMore.value)) {
return;
}
const cursor = reset ? null : nextCursor.value;
loading.value = reset;
loadingMore.value = !reset;
loadError.value = '';
if (reset) {
reactionUsers.value = [];
nextCursor.value = null;
hasMore.value = false;
total.value = 0;
}
try {
const page = await api.lifeReactionUsers(props.postId, {
cursor,
limit: pageSize,
reactionType: selectedReactionType()
});
reactionUsers.value = reset ? page.items : [...reactionUsers.value, ...page.items];
nextCursor.value = page.nextCursor;
hasMore.value = page.hasMore;
total.value = page.total;
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally {
loading.value = false;
loadingMore.value = false;
}
}
watch(
() => props.initialReactionType,
(nextReactionType) => {
activeReactionType.value = nextReactionType ?? 'all';
}
);
watch(
[() => props.postId, activeReactionType, locale],
() => {
void loadReactionUsers(true);
},
{ immediate: true }
);
</script>
<template>
<Modal :title="t('pages.life.reactionUsersTitle')" :subtitle="t('pages.life.reactionUsersSubtitle')" :close-label="t('common.close')" @close="emit('close')">
<div class="life-reaction-users-modal">
<Tabs id="life-reaction-users-filter" v-model="activeReactionType" :tabs="reactionTabs" :label="t('pages.life.reactionFiltersLabel')" />
<p class="life-reaction-users-modal__count">{{ t('pages.life.reactionsCount', { count: total }) }}</p>
<p v-if="loadError" class="life-form__error" role="alert">{{ loadError }}</p>
<div v-if="loading" class="life-reaction-user-list" aria-hidden="true">
<article v-for="index in 4" :key="index" class="life-reaction-user">
<Skeleton variant="box" width="38px" height="38px" />
<div class="life-reaction-user__copy">
<Skeleton width="140px" />
<Skeleton width="190px" />
</div>
</article>
</div>
<div v-else-if="reactionUsers.length" class="life-reaction-user-list">
<article v-for="item in reactionUsers" :key="`${item.user.id}-${item.reactedAt}`" class="life-reaction-user">
<RouterLink class="life-reaction-user__avatar" :to="`/profile/${item.user.id}`" :aria-label="item.user.displayName">
{{ item.user.displayName.slice(0, 1).toUpperCase() || '#' }}
</RouterLink>
<div class="life-reaction-user__copy">
<RouterLink class="user-profile-link" :to="`/profile/${item.user.id}`">
{{ item.user.displayName }}
</RouterLink>
<span>
<Icon :icon="reactionIcon(item.reactionType)" class="ui-icon" aria-hidden="true" />
{{ reactionLabel(item.reactionType) }}
<time :datetime="item.reactedAt">{{ formatReactedAt(item.reactedAt) }}</time>
</span>
</div>
</article>
</div>
<div v-else class="life-reaction-users-empty">
<Icon :icon="iconReactionLike" class="life-reaction-users-empty__icon" aria-hidden="true" />
<h3>{{ t('pages.life.reactionUsersEmpty') }}</h3>
</div>
<div v-if="hasMore && !loading" class="life-feed__retry">
<button class="ui-button ui-button--ghost ui-button--small" type="button" :disabled="loadingMore" @click="loadReactionUsers(false)">
{{ loadingMore ? t('common.loading') : t('pages.life.loadMoreReactions') }}
</button>
</div>
</div>
</Modal>
</template>

View File

@@ -404,6 +404,25 @@ export interface LifeCommentsPage {
total: number; 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 { export interface RecipeDetail extends Recipe {
acquisition_methods: NamedEntity[]; acquisition_methods: NamedEntity[];
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
@@ -1040,6 +1059,14 @@ export const api = {
setLifeReaction: (id: string | number, reactionType: LifeReactionType) => setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }), sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`), deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
lifeReactionUsers: (id: string | number, params: LifeReactionUsersParams = {}) =>
getJson<LifeReactionUsersPage>(
`/api/life-posts/${id}/reactions${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
reactionType: params.reactionType
})}`
),
setLifeRating: (id: string | number, rating: number) => setLifeRating: (id: string | number, rating: number) =>
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }), sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`), deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),

View File

@@ -2670,6 +2670,21 @@ button:disabled,
color: var(--ink-soft); 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 { .life-action-tooltip {
position: absolute; position: absolute;
z-index: 30; z-index: 30;
@@ -2868,6 +2883,101 @@ button:disabled,
margin: 0; 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 { .life-empty {
width: min(100%, 680px); width: min(100%, 680px);
justify-self: center; justify-self: center;
@@ -5870,6 +5980,26 @@ button:disabled,
gap: 6px; 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-feed-card__detail-link,
.profile-post-preview__detail { .profile-post-preview__detail {
display: inline-flex; display: inline-flex;

View File

@@ -4,6 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import LifeRatingControl from '../components/LifeRatingControl.vue'; import LifeRatingControl from '../components/LifeRatingControl.vue';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusBadge from '../components/StatusBadge.vue'; import StatusBadge from '../components/StatusBadge.vue';
@@ -61,6 +62,7 @@ const ratingBusyPostId = ref<number | null>(null);
const ratingErrors = ref<Record<number, string>>({}); const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null); const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({}); const moderationErrors = ref<Record<number, string>>({});
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
const lifeCommentPageSize = 20; const lifeCommentPageSize = 20;
const commentMaxLength = 1000; const commentMaxLength = 1000;
let removeAuthListener: (() => void) | null = null; 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) { function moderationLabel(status: AiModerationStatus) {
const labels: Record<AiModerationStatus, string> = { const labels: Record<AiModerationStatus, string> = {
unreviewed: t('pages.life.moderationUnreviewed'), unreviewed: t('pages.life.moderationUnreviewed'),
@@ -577,6 +587,13 @@ onUnmounted(() => {
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage> <StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
<LifeReactionUsersModal
v-if="reactionUsersModal"
:post-id="reactionUsersModal.postId"
:initial-reaction-type="reactionUsersModal.reactionType"
@close="closeReactionUsersModal"
/>
<div class="life-detail-layout" :aria-busy="loading || commentsLoading"> <div class="life-detail-layout" :aria-busy="loading || commentsLoading">
<article v-if="loading" class="life-post life-post--skeleton" aria-hidden="true"> <article v-if="loading" class="life-post life-post--skeleton" aria-hidden="true">
<div class="life-post__header"> <div class="life-post__header">
@@ -709,10 +726,12 @@ onUnmounted(() => {
</div> </div>
<div class="life-post__metrics"> <div class="life-post__metrics">
<div <button
v-if="reactionTotal(post) > 0" v-if="reactionTotal(post) > 0"
class="life-reaction-summary" class="life-reaction-summary life-reaction-summary--button"
type="button"
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })" :aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
@click="openReactionUsersModal(post.id)"
> >
<template v-for="option in reactionOptions" :key="option.type"> <template v-for="option in reactionOptions" :key="option.type">
<span <span
@@ -725,7 +744,7 @@ onUnmounted(() => {
<span class="life-action-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span> <span class="life-action-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span>
</span> </span>
</template> </template>
</div> </button>
<span class="life-metric-button life-metric-button--static" :aria-label="t('pages.life.commentsCount', { count: commentsTotal })"> <span class="life-metric-button life-metric-button--static" :aria-label="t('pages.life.commentsCount', { count: commentsTotal })">
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />

View File

@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import FilterPanel from '../components/FilterPanel.vue'; import FilterPanel from '../components/FilterPanel.vue';
import LifeRatingControl from '../components/LifeRatingControl.vue'; import LifeRatingControl from '../components/LifeRatingControl.vue';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
@@ -96,6 +97,7 @@ const ratingBusyPostId = ref<number | null>(null);
const ratingErrors = ref<Record<number, string>>({}); const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null); const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({}); const moderationErrors = ref<Record<number, string>>({});
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null); const bodyInput = ref<HTMLTextAreaElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null); const loadMoreSentinel = ref<HTMLElement | null>(null);
const lifePostPageSize = 20; const lifePostPageSize = 20;
@@ -541,6 +543,14 @@ function reactionCountLabel(post: LifePost, type: LifeReactionType) {
}); });
} }
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
reactionUsersModal.value = { postId, reactionType };
}
function closeReactionUsersModal() {
reactionUsersModal.value = null;
}
function moderationLabel(status: AiModerationStatus) { function moderationLabel(status: AiModerationStatus) {
const labels: Record<AiModerationStatus, string> = { const labels: Record<AiModerationStatus, string> = {
unreviewed: t('pages.life.moderationUnreviewed'), unreviewed: t('pages.life.moderationUnreviewed'),
@@ -1189,6 +1199,13 @@ onUnmounted(() => {
</div> </div>
</Modal> </Modal>
<LifeReactionUsersModal
v-if="reactionUsersModal"
:post-id="reactionUsersModal.postId"
:initial-reaction-type="reactionUsersModal.reactionType"
@close="closeReactionUsersModal"
/>
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" /> <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')" /> <Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
@@ -1363,10 +1380,12 @@ onUnmounted(() => {
</div> </div>
<div class="life-post__metrics"> <div class="life-post__metrics">
<div <button
v-if="reactionTotal(post) > 0" v-if="reactionTotal(post) > 0"
class="life-reaction-summary" class="life-reaction-summary life-reaction-summary--button"
type="button"
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })" :aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
@click="openReactionUsersModal(post.id)"
> >
<template v-for="option in reactionOptions" :key="option.type"> <template v-for="option in reactionOptions" :key="option.type">
<span <span
@@ -1379,7 +1398,7 @@ onUnmounted(() => {
<span class="life-action-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span> <span class="life-action-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span>
</span> </span>
</template> </template>
</div> </button>
<button <button
class="life-metric-button" class="life-metric-button"

View File

@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusBadge from '../components/StatusBadge.vue'; import StatusBadge from '../components/StatusBadge.vue';
@@ -92,6 +93,7 @@ const commentsCursor = ref<string | null>(null);
const commentsHasMore = ref(false); const commentsHasMore = ref(false);
const commentsLoading = ref(false); const commentsLoading = ref(false);
const commentsError = ref(''); const commentsError = ref('');
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
const activityLimit = 10; const activityLimit = 10;
let profileRequestId = 0; let profileRequestId = 0;
@@ -574,6 +576,14 @@ function reactionLabel(type: LifeReactionType): string {
return t(`pages.life.reaction${type.charAt(0).toUpperCase()}${type.slice(1)}`); return t(`pages.life.reaction${type.charAt(0).toUpperCase()}${type.slice(1)}`);
} }
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
reactionUsersModal.value = { postId, reactionType };
}
function closeReactionUsersModal() {
reactionUsersModal.value = null;
}
function contributionCategory(contentType: string): ContributionFilter { function contributionCategory(contentType: string): ContributionFilter {
return primaryContributionFilters.includes(contentType as PrimaryContributionFilter) return primaryContributionFilters.includes(contentType as PrimaryContributionFilter)
? (contentType as PrimaryContributionFilter) ? (contentType as PrimaryContributionFilter)
@@ -693,6 +703,13 @@ onMounted(() => {
<Tabs id="profile-tabs" v-model="activeTab" :tabs="tabs" :label="t('pages.profile.tabsLabel')" /> <Tabs id="profile-tabs" v-model="activeTab" :tabs="tabs" :label="t('pages.profile.tabsLabel')" />
<LifeReactionUsersModal
v-if="reactionUsersModal"
:post-id="reactionUsersModal.postId"
:initial-reaction-type="reactionUsersModal.reactionType"
@close="closeReactionUsersModal"
/>
<section v-if="activeTab === 'feeds'" class="profile-tab-panel" :aria-label="t('pages.profile.tabFeeds')"> <section v-if="activeTab === 'feeds'" class="profile-tab-panel" :aria-label="t('pages.profile.tabFeeds')">
<StatusMessage v-if="feedsError" variant="danger" :duration="0">{{ feedsError }}</StatusMessage> <StatusMessage v-if="feedsError" variant="danger" :duration="0">{{ feedsError }}</StatusMessage>
@@ -733,10 +750,15 @@ onMounted(() => {
</div> </div>
<div class="profile-feed-card__metrics"> <div class="profile-feed-card__metrics">
<span> <button
class="profile-reaction-open-button"
type="button"
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
@click="openReactionUsersModal(post.id)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.reactionsCount', { count: reactionTotal(post) }) }} {{ t('pages.life.reactionsCount', { count: reactionTotal(post) }) }}
</span> </button>
<span> <span>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.commentsCount', { count: commentTotal(post) }) }} {{ t('pages.life.commentsCount', { count: commentTotal(post) }) }}
@@ -860,10 +882,15 @@ onMounted(() => {
<div v-else-if="reactions.length" class="profile-activity-list"> <div v-else-if="reactions.length" class="profile-activity-list">
<article v-for="activity in reactions" :key="`${activity.postId}-${activity.reactedAt}`" class="profile-activity-card"> <article v-for="activity in reactions" :key="`${activity.postId}-${activity.reactedAt}`" class="profile-activity-card">
<header class="profile-activity-card__header"> <header class="profile-activity-card__header">
<span> <button
class="profile-reaction-open-button"
type="button"
:aria-label="reactionLabel(activity.reactionType)"
@click="openReactionUsersModal(activity.post.id, activity.reactionType)"
>
<Icon :icon="reactionIcon(activity.reactionType)" class="ui-icon" aria-hidden="true" /> <Icon :icon="reactionIcon(activity.reactionType)" class="ui-icon" aria-hidden="true" />
{{ reactionLabel(activity.reactionType) }} {{ reactionLabel(activity.reactionType) }}
</span> </button>
<time :datetime="activity.reactedAt">{{ formatDateTime(activity.reactedAt) }}</time> <time :datetime="activity.reactedAt">{{ formatDateTime(activity.reactedAt) }}</time>
</header> </header>

View File

@@ -841,6 +841,12 @@ export const systemWordingMessages = {
reactionThanks: 'Thanks', reactionThanks: 'Thanks',
chooseReaction: 'Choose reaction', chooseReaction: 'Choose reaction',
reactionMenu: 'Reaction menu', reactionMenu: 'Reaction menu',
reactionUsersTitle: 'Reactions',
reactionUsersSubtitle: 'People who reacted to this Life post.',
reactionFiltersLabel: 'Reaction types',
allReactions: 'All reactions',
reactionUsersEmpty: 'No reactions yet',
loadMoreReactions: 'Load more reactions',
removeReaction: 'Remove reaction', removeReaction: 'Remove reaction',
reactionFailed: 'Reaction failed', reactionFailed: 'Reaction failed',
postMeta: 'Post details', postMeta: 'Post details',
@@ -2072,6 +2078,12 @@ export const systemWordingMessages = {
reactionThanks: '感谢', reactionThanks: '感谢',
chooseReaction: '选择互动', chooseReaction: '选择互动',
reactionMenu: '互动菜单', reactionMenu: '互动菜单',
reactionUsersTitle: '互动',
reactionUsersSubtitle: '查看对这条 Life 动态做出互动的用户。',
reactionFiltersLabel: '互动类型',
allReactions: '全部互动',
reactionUsersEmpty: '暂无互动',
loadMoreReactions: '加载更多互动',
removeReaction: '取消互动', removeReaction: '取消互动',
reactionFailed: '互动失败', reactionFailed: '互动失败',
postMeta: '动态信息', postMeta: '动态信息',