Compare commits

..

5 Commits

Author SHA1 Message Date
504849c14a feat(search): include user profiles in global search results
Add users group to global search API and frontend types
Query users by display name and link to their public profiles
Update system wordings for the new search group
2026-05-04 16:04:58 +08:00
8cb8190554 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
2026-05-04 15:49:57 +08:00
016364a8b8 feat(life): allow authors to view and restore their deleted comments
Update backend to return soft-deleted comments to their authors
Add restore endpoint and frontend Undo button for deleted comments
Retain comment body and author information upon deletion
2026-05-04 14:54:00 +08:00
b0e2036965 feat(life): hide deleted comments and approved moderation status
Completely remove deleted comments and their replies from lists, previews, and counts.
Hide the "approved" moderation status badge to reduce visual clutter.
2026-05-04 14:53:45 +08:00
06e0cbb1c1 feat(search): add global search across wiki entities
Implement /api/search endpoint for cross-entity querying
Add GlobalSearch component to top navigation bar with categorized results
2026-05-04 14:20:12 +08:00
15 changed files with 1605 additions and 56 deletions

View File

@@ -9,6 +9,7 @@
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口Logo 导航回到 Home用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
- 全局顶部导航栏提供全站搜索。搜索结果按内容类型分组展示,覆盖 Pokemon、Habitats、Items、Ancient Artifacts、Recipes、Daily CheckList、公开可见的 Life Post 和公开用户 Profile结果跳转到对应公开详情页、页面锚点或 `/profile/:id`
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
## 技术栈
@@ -23,6 +24,7 @@
- `DESIGN.md` 是产品行为、数据结构和 API 暴露边界的单一事实来源。
- API 只返回业务需要的字段不返回密码、token hash、验证 token、内部调试字段或不必要的元数据。
- 全局搜索 API 只返回公开浏览所需的最小结果字段结果类型、ID、展示标题、目标 URL、可选摘要和可选图片用户搜索结果只使用公开 Profile 所需的 `id``displayName` 和目标 URL不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
@@ -125,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 来源查看。
@@ -254,6 +260,7 @@
- 通知和审核状态实时更新可以走 WebSocketWebSocket 连接使用短期一次性 ticket不把 session token 放入 WebSocket URL。
- AI 审核从 `reviewing` 变更为 `approved``rejected``failed` 后,前端当前可见的对应 Life Post、Life Comment 或实体讨论评论状态、语言区和可展示的审核原因详情应通过 WebSocket 直接更新,不要求用户刷新页面。
- 通知范围:
- 用户被别人 Follow 时,通知被 Follow 的用户;同一用户重复 Follow 同一目标时合并更新同一通知。
- Life Post 收到审核通过后的顶层评论时,通知 Life Post 作者。
- Life Comment 收到审核通过后的回复时,通知父评论作者。
- 实体讨论评论收到审核通过后的回复时,通知父评论作者。
@@ -275,6 +282,7 @@
- `updatedAt`
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
- Follow 对象发布 Life Post 的动态属于 Following Feed不进入 Notifications不产生未读数量也不需要标记已读。
## 滥用防护与限流
@@ -815,7 +823,7 @@ Life Post 可配置:
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
- 已注册并完成邮箱验证且拥有 `life.posts.create``life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post并回复顶层评论。
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 仅对该评论作者本人可见并保留正文,作者可通过 Undo 恢复;其他用户不可见,不显示 Deleted Comment 占位,不出现在评论列表、评论预览或评论数量中
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
@@ -832,14 +840,15 @@ Life Post 可配置:
- Feed 支持按 Game Version 筛选All versions 表示不过滤版本。
- Feed 支持 Rateable 筛选All 表示不过滤Rateable only 只展示可评分 Category 下的 Post。
- Feed 支持排序Latest 默认按创建时间倒序Oldest 按创建时间正序Top rated 按平均评分倒序,同分时按创建时间倒序。
- 登录用户可切换 All Feed 和 Following FeedFollowing Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post并继续支持 Life Category、语言、Game Version、Rateable 和排序筛选。
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
- 当前没有图片上传、转发或置顶。
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
- 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。
- Life Post 必须展示审核状态:审核中、未审核、审核失败、审核不通过;审核通过也可作为状态展示
- Life Post 必须展示未通过或未完成的审核状态:审核中、未审核、审核失败、审核不通过;审核通过不显示状态标签
- 新增或更新 Life Post 后先进入不可公开状态AI 审核通过后才出现在普通公开 Feed。
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed``rejected``failed` 这类非进行中且未通过状态可触发重新审核。
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
@@ -1001,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
@@ -1061,6 +1073,7 @@ API 暴露边界:
- `POST /api/life-posts/:postId/comments`
- `POST /api/life-posts/:postId/comments/:commentId/replies`
- `DELETE /api/life-comments/:id`
- `POST /api/life-comments/:id/restore`
- `POST /api/life-comments/:id/moderation/retry`
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
- `POST /api/discussions/:entityType/:entityId/comments`

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
@@ -36,6 +36,32 @@ type DataToolsBundle = {
scopes: DataToolScope[];
data: Partial<Record<DataToolScope, DataToolScopeData>>;
};
type GlobalSearchGroupType =
| 'pokemon'
| 'habitats'
| 'items'
| 'ancient-artifacts'
| 'recipes'
| 'daily-checklist'
| 'life'
| 'users';
type GlobalSearchItem = {
id: number;
type: GlobalSearchGroupType;
title: string;
url: string;
summary: string | null;
meta: string | null;
image: EntityImageValue | PokemonImage | null;
};
type GlobalSearchGroup = {
type: GlobalSearchGroupType;
items: GlobalSearchItem[];
};
type GlobalSearchResults = {
query: string;
groups: GlobalSearchGroup[];
};
type TranslationField = 'name' | 'title' | 'details' | 'genus';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
@@ -328,6 +354,7 @@ type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type LifePostFilters = {
authorId?: number;
followedByUserId?: number;
};
type LifePostsPage = {
@@ -370,9 +397,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[];
};
@@ -2411,6 +2448,197 @@ export async function listDailyChecklistItems(locale = defaultLocale) {
);
}
export async function globalSearch(paramsQuery: QueryParams = {}, locale = defaultLocale): Promise<GlobalSearchResults> {
const search = asString(paramsQuery.query)?.trim() ?? asString(paramsQuery.search)?.trim() ?? '';
if (!search) {
return { query: '', groups: [] };
}
const pattern = `%${search}%`;
const limit = 5;
const pokemonName = localizedName('pokemon', 'p', locale);
const habitatName = localizedName('habitats', 'h', locale);
const itemName = localizedName('items', 'i', locale);
const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
const artifactName = localizedName('ancient-artifacts', 'a', locale);
const artifactCategoryName = systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale);
const recipeItemName = localizedName('items', 'result_item', locale);
const recipeMaterialName = localizedName('items', 'material_item', locale);
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
const lifeCategoryName = localizedName('life-tags', 'lc', locale);
const [pokemon, habitats, items, artifacts, recipes, checklist, life, users] = await Promise.all([
query<GlobalSearchItem>(
`
SELECT
p.id,
'pokemon' AS type,
${pokemonName} AS title,
'/pokemon/' || p.id AS url,
NULLIF(p.genus, '') AS summary,
'#' || p.display_id::text AS meta,
${pokemonImageJson('p')} AS image
FROM pokemon p
WHERE ${pokemonName} ILIKE $1
ORDER BY ${orderByEntity('p')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
h.id,
'habitats' AS type,
${habitatName} AS title,
'/habitats/' || h.id AS url,
NULL AS summary,
NULL AS meta,
${uploadedImageJson('h.image_path')} AS image
FROM habitats h
WHERE ${habitatName} ILIKE $1
ORDER BY ${orderByEntity('h')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
i.id,
'items' AS type,
${itemName} AS title,
'/items/' || i.id AS url,
NULLIF(i.details, '') AS summary,
(${itemCategoryName}->>'name') AS meta,
${uploadedImageJson('i.image_path')} AS image
FROM items i
WHERE ${itemName} ILIKE $1
ORDER BY i.display_id, ${orderByEntity('i')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
a.id,
'ancient-artifacts' AS type,
${artifactName} AS title,
'/ancient-artifacts/' || a.id AS url,
NULLIF(a.details, '') AS summary,
(${artifactCategoryName}->>'name') AS meta,
${uploadedImageJson('a.image_path')} AS image
FROM ancient_artifacts a
WHERE ${artifactName} ILIKE $1
ORDER BY a.display_id, ${orderByEntity('a')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
r.id,
'recipes' AS type,
${recipeItemName} AS title,
'/recipes/' || r.id AS url,
(
SELECT string_agg(material_rows.name, ' / ' ORDER BY material_rows.name)
FROM (
SELECT DISTINCT ${recipeMaterialName} AS name
FROM recipe_materials rm
JOIN items material_item ON material_item.id = rm.item_id
WHERE rm.recipe_id = r.id
) material_rows
) AS summary,
NULL AS meta,
${uploadedImageJson('result_item.image_path')} AS image
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
WHERE ${recipeItemName} ILIKE $1
OR EXISTS (
SELECT 1
FROM recipe_materials rm
JOIN items material_item ON material_item.id = rm.item_id
WHERE rm.recipe_id = r.id
AND ${recipeMaterialName} ILIKE $1
)
ORDER BY ${orderByEntity('r')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
c.id,
'daily-checklist' AS type,
${checklistTitle} AS title,
'/checklist' AS url,
NULL AS summary,
NULL AS meta,
NULL AS image
FROM daily_checklist_items c
WHERE ${checklistTitle} ILIKE $1
ORDER BY ${orderByEntity('c')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
lp.id,
'life' AS type,
LEFT(lp.body, 120) AS title,
'/life/' || lp.id AS url,
NULL AS summary,
${lifeCategoryName} AS meta,
NULL AS image
FROM life_posts lp
LEFT JOIN life_tags lc ON lc.id = lp.category_id
WHERE lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
AND lp.body ILIKE $1
ORDER BY lp.created_at DESC, lp.id DESC
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
u.id,
'users' AS type,
u.display_name AS title,
'/profile/' || u.id AS url,
NULL AS summary,
NULL AS meta,
NULL AS image
FROM users u
WHERE u.display_name ILIKE $1
ORDER BY lower(u.display_name), u.id
LIMIT $2
`,
[pattern, limit]
)
]);
const groups: GlobalSearchGroup[] = [
{ type: 'pokemon', items: pokemon },
{ type: 'habitats', items: habitats },
{ type: 'items', items: items },
{ type: 'ancient-artifacts', items: artifacts },
{ type: 'recipes', items: recipes },
{ type: 'daily-checklist', items: checklist },
{ type: 'life', items: life },
{ type: 'users', items: users }
];
return { query: search, groups: groups.filter((group) => group.items.length > 0) };
}
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
return queryOne(
@@ -2879,7 +3107,7 @@ function lifeCommentProjection(whereClause: string): string {
lc.id,
lc.post_id AS "postId",
lc.parent_comment_id AS "parentCommentId",
CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body,
lc.body,
lc.deleted_at IS NOT NULL AS deleted,
lc.ai_moderation_status AS "moderationStatus",
lc.ai_moderation_language_code AS "moderationLanguageCode",
@@ -2887,16 +3115,36 @@ function lifeCommentProjection(whereClause: string): string {
lc.created_at AS "createdAt",
lc.created_at::text AS "createdAtCursor",
lc.updated_at AS "updatedAt",
CASE
WHEN lc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
END AS author
CASE WHEN comment_user.id IS NULL THEN NULL ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) END AS author
FROM life_post_comments lc
LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
${whereClause}
`;
}
function visibleLifeCommentExpression(alias: string, ownerColumn: string, userParamIndex: number | null): string {
return userParamIndex !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = $${userParamIndex})` : `${alias}.deleted_at IS NULL`;
}
function addVisibleLifeCommentCondition(conditions: string[], params: unknown[], userId: number | null): void {
const userParamIndex = params.length + 1;
if (userId !== null) {
params.push(userId);
}
conditions.push(visibleLifeCommentExpression('lc', 'lc.created_by_user_id', userId === null ? null : userParamIndex));
conditions.push(`
(
lc.parent_comment_id IS NULL
OR EXISTS (
SELECT 1
FROM life_post_comments parent_comment
WHERE parent_comment.id = lc.parent_comment_id
AND ${visibleLifeCommentExpression('parent_comment', 'parent_comment.created_by_user_id', userId === null ? null : userParamIndex)}
)
)
`);
}
function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
const comments = new Map<number, LifeComment>();
const topLevelComments: LifeComment[] = [];
@@ -2939,6 +3187,7 @@ async function lifeCommentCountsForPosts(
const params: unknown[] = [postIds];
const conditions = ['lc.post_id = ANY($1::integer[])'];
addVisibleLifeCommentCondition(conditions, params, userId);
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
const rows = await query<{ postId: number; total: number }>(
@@ -2970,6 +3219,7 @@ async function lifeCommentPreviewForPosts(
const params: unknown[] = [postIds];
const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
addVisibleLifeCommentCondition(previewConditions, params, userId);
addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
params.push(lifeCommentPreviewLimit);
@@ -3029,6 +3279,7 @@ export async function listLifeComments(
const params: unknown[] = [postId];
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
addVisibleLifeCommentCondition(topLevelConditions, params, userId);
addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
@@ -3053,6 +3304,7 @@ export async function listLifeComments(
? await (async () => {
const replyParams: unknown[] = [topLevelIds];
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
addVisibleLifeCommentCondition(replyConditions, replyParams, userId);
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
return query<LifeCommentRow>(
@@ -3066,6 +3318,7 @@ export async function listLifeComments(
: [];
const totalParams: unknown[] = [postId];
const totalConditions = ['lc.post_id = $1'];
addVisibleLifeCommentCondition(totalConditions, totalParams, userId);
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
const total = await queryOne<{ total: number }>(
@@ -3312,6 +3565,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);
@@ -3393,6 +3658,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>(
@@ -3412,7 +3686,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;
@@ -3479,6 +3814,8 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
[user.id]
);
const social = await getPublicProfileSocial(user.id, viewerUserId);
return {
user,
stats: stats ?? {
@@ -3492,6 +3829,7 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
lifeReactions: 0,
discussionComments: 0
},
social,
contributions: contributions.map((item) => ({
...item,
contentType: publicContributionType(item.contentType)
@@ -3499,6 +3837,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 = {},
@@ -4163,6 +4552,29 @@ export async function deleteLifeComment(id: number, userId: number, allowAny = f
return Boolean(result);
}
export async function restoreLifeComment(id: number, userId: number) {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
const result = await queryOne<{ id: number }>(
`
UPDATE life_post_comments
SET deleted_at = NULL, deleted_by_user_id = NULL, updated_at = now()
WHERE id = $1
AND created_by_user_id = $2
AND deleted_at IS NOT NULL
AND EXISTS (
SELECT 1
FROM life_posts lp
WHERE lp.id = life_post_comments.post_id
AND lp.deleted_at IS NULL
)
RETURNING id
`,
[commentId, userId]
);
return result ? getLifeCommentById(result.id) : null;
}
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
const row = await queryOne<{ id: number }>(

View File

@@ -64,6 +64,7 @@ import {
exportAdminData,
fetchPokemonData,
fetchPokemonImageOptions,
followUser,
getAdminDataToolsSummary,
getAncientArtifact,
getHabitat,
@@ -73,6 +74,7 @@ import {
getPokemon,
getPublicUserProfile,
getRecipe,
globalSearch,
importAdminData,
isConfigType,
listAncientArtifacts,
@@ -80,6 +82,7 @@ import {
listConfig,
listDailyChecklistItems,
listHabitats,
listFollowingLifePosts,
listItems,
listLifeComments,
listLanguages,
@@ -102,6 +105,7 @@ import {
retryEntityDiscussionCommentModeration,
retryLifeCommentModeration,
retryLifePostModeration,
restoreLifeComment,
setLifePostRating,
setLifePostReaction,
updateConfig,
@@ -113,6 +117,7 @@ import {
updateLifePost,
updatePokemon,
updateRecipe,
unfollowUser,
wipeAdminData
} from './queries.ts';
import {
@@ -219,6 +224,10 @@ app.setErrorHandler(async (error, _request, reply) => {
app.get('/health', async () => ({ ok: true }));
app.get('/api/search', async (request) =>
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
function getBearerToken(authorization: string | undefined): string | null {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;
@@ -1178,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);
});
@@ -1233,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);
@@ -1414,6 +1460,16 @@ app.delete('/api/life-comments/:id', async (request, reply) => {
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/life-comments/:id/restore', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.delete', 'communityWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await restoreLifeComment(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.post('/api/life-comments/:id/moderation/retry', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,

View File

@@ -16,6 +16,7 @@ import {
type AppIcon
} from '../icons';
import type { AuthUser, Language } from '../services/api';
import GlobalSearch from './GlobalSearch.vue';
import NotificationBell from './NotificationBell.vue';
import PokeBallMark from './PokeBallMark.vue';
import StatusBadge from './StatusBadge.vue';
@@ -271,6 +272,8 @@ onBeforeUnmount(() => {
</RouterLink>
</div>
<GlobalSearch class="site-topbar__search" @navigate="closeSidebar" />
<div class="site-topbar__spacer" aria-hidden="true"></div>
<div class="topbar-actions">

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { iconClose, iconSearch } from '../icons';
import { api, type GlobalSearchGroup, type GlobalSearchGroupType, type GlobalSearchItem } from '../services/api';
const emit = defineEmits<{
navigate: [];
}>();
const { t } = useI18n();
const router = useRouter();
const root = ref<HTMLElement | null>(null);
const input = ref<HTMLInputElement | null>(null);
const query = ref('');
const groups = ref<GlobalSearchGroup[]>([]);
const open = ref(false);
const mobileOpen = ref(false);
const loading = ref(false);
const failed = ref(false);
let searchTimeout: number | null = null;
let abortController: AbortController | null = null;
let requestId = 0;
const cleanQuery = computed(() => query.value.trim());
const hasResults = computed(() => groups.value.some((group) => group.items.length > 0));
const firstResult = computed(() => groups.value.find((group) => group.items.length > 0)?.items[0] ?? null);
const panelVisible = computed(() => open.value && cleanQuery.value !== '' && (loading.value || failed.value || groups.value.length > 0));
const groupLabels: Record<GlobalSearchGroupType, string> = {
pokemon: 'search.groups.pokemon',
habitats: 'search.groups.habitats',
items: 'search.groups.items',
'ancient-artifacts': 'search.groups.ancientArtifacts',
recipes: 'search.groups.recipes',
'daily-checklist': 'search.groups.dailyChecklist',
life: 'search.groups.life',
users: 'search.groups.users'
};
function clearSearchTimeout() {
if (searchTimeout !== null) {
window.clearTimeout(searchTimeout);
searchTimeout = null;
}
}
function abortSearch() {
abortController?.abort();
abortController = null;
}
function resetResults() {
groups.value = [];
failed.value = false;
loading.value = false;
}
async function runSearch(value: string) {
const currentRequestId = ++requestId;
abortSearch();
const controller = new AbortController();
abortController = controller;
loading.value = true;
failed.value = false;
try {
const response = await api.globalSearch(value, controller.signal);
if (currentRequestId === requestId) {
groups.value = response.groups;
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
if (currentRequestId === requestId) {
groups.value = [];
failed.value = true;
}
} finally {
if (currentRequestId === requestId) {
loading.value = false;
if (abortController === controller) {
abortController = null;
}
}
}
}
function scheduleSearch() {
clearSearchTimeout();
const value = cleanQuery.value;
if (!value) {
requestId += 1;
abortSearch();
resetResults();
return;
}
requestId += 1;
abortSearch();
loading.value = true;
failed.value = false;
searchTimeout = window.setTimeout(() => {
searchTimeout = null;
void runSearch(value);
}, 240);
}
function openPanel() {
open.value = true;
}
function closePanel() {
open.value = false;
}
function toggleMobileSearch() {
mobileOpen.value = !mobileOpen.value;
openPanel();
if (mobileOpen.value) {
void nextTick(() => input.value?.focus());
}
}
function clearQuery() {
query.value = '';
resetResults();
openPanel();
void nextTick(() => input.value?.focus());
}
function onSubmit() {
const item = firstResult.value;
if (!item) {
openPanel();
return;
}
void navigateTo(item);
}
async function navigateTo(item: GlobalSearchItem) {
selectResult();
await router.push(item.url);
}
function selectResult() {
closePanel();
mobileOpen.value = false;
emit('navigate');
}
function onRootKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
closePanel();
input.value?.blur();
}
}
function onDocumentPointerDown(event: PointerEvent) {
if (root.value && !root.value.contains(event.target as Node)) {
closePanel();
}
}
function groupLabel(type: GlobalSearchGroupType) {
return t(groupLabels[type]);
}
watch(query, scheduleSearch);
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown);
});
onBeforeUnmount(() => {
clearSearchTimeout();
abortSearch();
document.removeEventListener('pointerdown', onDocumentPointerDown);
});
</script>
<template>
<div
ref="root"
class="global-search"
:class="{ 'global-search--mobile-open': mobileOpen }"
@keydown="onRootKeydown"
>
<button
class="global-search__toggle"
type="button"
:aria-label="t('search.open')"
:aria-expanded="mobileOpen"
@click="toggleMobileSearch"
>
<Icon :icon="mobileOpen ? iconClose : iconSearch" class="ui-icon" aria-hidden="true" />
</button>
<form class="global-search__form" role="search" @submit.prevent="onSubmit">
<Icon :icon="iconSearch" class="ui-icon global-search__form-icon" aria-hidden="true" />
<input
ref="input"
v-model="query"
class="global-search__input"
type="search"
:placeholder="t('search.placeholder')"
:aria-label="t('search.label')"
:aria-controls="panelVisible ? 'global-search-results' : undefined"
:aria-expanded="panelVisible"
autocomplete="off"
@focus="openPanel"
/>
<button
v-if="cleanQuery"
class="global-search__clear"
type="button"
:aria-label="t('search.clear')"
@click="clearQuery"
>
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
</button>
</form>
<div
v-if="panelVisible"
id="global-search-results"
class="global-search__panel"
:aria-busy="loading"
>
<div v-if="loading" class="global-search__skeleton" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<p v-else-if="failed" class="global-search__message">{{ t('search.failed') }}</p>
<p v-else-if="!hasResults" class="global-search__message">{{ t('search.empty') }}</p>
<template v-else>
<section
v-for="group in groups"
:key="group.type"
class="global-search__group"
:aria-label="groupLabel(group.type)"
>
<h2 class="global-search__group-title">{{ groupLabel(group.type) }}</h2>
<RouterLink
v-for="item in group.items"
:key="`${group.type}-${item.id}`"
class="global-search__result"
:to="item.url"
@click="selectResult"
>
<img
v-if="item.image"
class="global-search__result-image"
:src="item.image.url"
:alt="item.title"
loading="lazy"
/>
<span v-else class="global-search__result-mark" aria-hidden="true">
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
</span>
<span class="global-search__result-copy">
<span class="global-search__result-title">{{ item.title }}</span>
<span v-if="item.summary || item.meta" class="global-search__result-meta">
<span v-if="item.meta">{{ item.meta }}</span>
<span v-if="item.summary">{{ item.summary }}</span>
</span>
</span>
</RouterLink>
</section>
</template>
</div>
</div>
</template>

View File

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

View File

@@ -54,6 +54,7 @@ export const iconStar: AppIcon = 'mdi:star';
export const iconStarOutline: AppIcon = 'mdi:star-outline';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate';
export const iconUndo: AppIcon = 'mdi:undo';
export const iconUpload: AppIcon = 'mdi:upload-outline';
export const iconVersion: AppIcon = 'mdi:tag-outline';
export const iconWarning: AppIcon = 'mdi:alert-outline';

View File

@@ -318,6 +318,36 @@ export interface DailyChecklistItem {
translations?: TranslationMap;
}
export type GlobalSearchGroupType =
| 'pokemon'
| 'habitats'
| 'items'
| 'ancient-artifacts'
| 'recipes'
| 'daily-checklist'
| 'life'
| 'users';
export interface GlobalSearchItem {
id: number;
type: GlobalSearchGroupType;
title: string;
url: string;
summary: string | null;
meta: string | null;
image: EntityImage | PokemonImage | null;
}
export interface GlobalSearchGroup {
type: GlobalSearchGroupType;
items: GlobalSearchItem[];
}
export interface GlobalSearchResults {
query: string;
groups: GlobalSearchGroup[];
}
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
export interface DataToolScopeSummary {
@@ -344,9 +374,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;
@@ -438,6 +469,7 @@ export interface NotificationTarget {
id: number;
path: string;
lifePostId: number | null;
profileUserId: number | null;
lifeCommentId: number | null;
discussionCommentId: number | null;
entityType: DiscussionEntityType | null;
@@ -556,9 +588,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[];
}
@@ -1033,6 +1075,8 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
}
export const api = {
globalSearch: (query: string, signal?: AbortSignal) =>
getJson<GlobalSearchResults>(`/api/search${buildQuery({ query: query.trim() })}`, signal),
languages: () => getJson<Language[]>('/api/languages'),
projectUpdates: (params: ProjectUpdatesParams = {}) =>
getJson<ProjectUpdates>(
@@ -1088,6 +1132,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({
@@ -1177,6 +1236,7 @@ export const api = {
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
retryLifeCommentModeration: (id: string | number) =>
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
restoreLifeComment: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/restore`, 'POST', {}),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
getJson<EntityDiscussionCommentsPage>(

View File

@@ -159,6 +159,190 @@ svg {
flex: 1 1 auto;
}
.site-topbar__search {
flex: 0 1 520px;
}
.global-search {
position: relative;
min-width: 220px;
}
.global-search__toggle {
display: none;
}
.global-search__form {
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
box-shadow: 0 3px 0 var(--line-strong);
padding: 0 10px;
transition:
border-color 0.14s ease,
box-shadow 0.14s ease;
}
.global-search__form:focus-within {
border-color: var(--pokemon-blue);
box-shadow: 0 3px 0 var(--pokemon-blue-deep);
}
.global-search__form-icon {
width: 20px;
height: 20px;
color: var(--muted);
}
.global-search__input {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
font-size: 0.94rem;
font-weight: 700;
}
.global-search__input::placeholder {
color: var(--muted);
opacity: 1;
}
.global-search__clear {
width: 30px;
min-width: 30px;
min-height: 30px;
display: inline-grid;
place-items: center;
border-radius: var(--radius-small);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.global-search__clear:hover {
background: var(--surface-soft);
color: var(--ink-soft);
}
.global-search__panel {
position: absolute;
inset: calc(100% + 8px) 0 auto 0;
z-index: 80;
max-height: min(70dvh, 620px);
overflow: auto;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-raised);
padding: 10px;
}
.global-search__group + .global-search__group {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.global-search__group-title {
margin: 0 0 6px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.global-search__result {
min-height: 58px;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 8px;
border-radius: var(--radius-control);
color: var(--ink);
}
.global-search__result:hover {
background: var(--surface-soft);
}
.global-search__result-image,
.global-search__result-mark {
width: 40px;
height: 40px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
}
.global-search__result-image {
object-fit: contain;
}
.global-search__result-mark {
display: inline-grid;
place-items: center;
color: var(--muted);
}
.global-search__result-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.global-search__result-title,
.global-search__result-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.global-search__result-title {
color: var(--ink);
font-size: 0.94rem;
font-weight: 900;
}
.global-search__result-meta {
display: flex;
gap: 8px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
.global-search__message {
margin: 0;
padding: 14px 10px;
color: var(--muted);
font-size: 0.9rem;
font-weight: 800;
text-align: center;
}
.global-search__skeleton {
display: grid;
gap: 8px;
}
.global-search__skeleton span {
height: 48px;
border-radius: var(--radius-control);
background: linear-gradient(90deg, var(--surface-soft), var(--line), var(--surface-soft));
background-size: 220% 100%;
animation: shimmer 1.4s linear infinite;
}
.topbar-actions {
min-width: 0;
display: flex;
@@ -6203,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));
}
@@ -6864,6 +7059,53 @@ button:disabled,
display: none;
}
.site-topbar__search {
flex: 0 0 auto;
min-width: 0;
}
.global-search {
position: static;
min-width: 0;
}
.global-search__toggle {
width: 44px;
min-width: 44px;
min-height: 44px;
display: inline-grid;
place-items: center;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink-soft);
cursor: pointer;
}
.global-search__toggle:hover {
border-color: var(--pokemon-blue);
color: var(--pokemon-blue-deep);
}
.global-search__form {
display: none;
}
.global-search--mobile-open .global-search__form {
position: fixed;
top: 68px;
right: 12px;
left: 12px;
z-index: 80;
display: flex;
}
.global-search__panel {
position: fixed;
inset: 122px 12px auto 12px;
max-height: calc(100dvh - 138px);
}
.topbar-actions {
flex: 0 0 auto;
gap: 6px;

View File

@@ -21,6 +21,7 @@ import {
iconReactionLike,
iconReactionThanks,
iconReply,
iconUndo,
iconVersion,
iconWarning
} from '../icons';
@@ -149,6 +150,25 @@ function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
}
function replaceCommentInTree(items: LifeComment[], updated: LifeComment): boolean {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (!item) {
continue;
}
if (item.id === updated.id) {
items[index] = { ...updated, replies: item.replies };
return true;
}
if (replaceCommentInTree(item.replies, updated)) {
return true;
}
}
return false;
}
async function loadComments(reset = false) {
const currentPost = post.value;
if (!currentPost || commentsLoading.value || commentsLoadingMore.value || (!reset && commentsLoaded.value && !commentsHasMore.value)) {
@@ -208,8 +228,12 @@ function canManageComment(comment: LifeComment) {
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
}
function canRestoreComment(comment: LifeComment) {
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
}
function canSeeCommentModeration(comment: LifeComment) {
return currentUser.value?.id === comment.author?.id || can('life.comments.delete-any');
return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'));
}
function canUseReactions() {
@@ -277,6 +301,10 @@ function moderationTone(status: AiModerationStatus) {
return tones[status];
}
function moderationStatusVisible(status: AiModerationStatus) {
return status !== 'approved';
}
function canRetryModeration(currentPost: LifePost) {
return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost);
}
@@ -554,16 +582,39 @@ async function submitReply(currentPost: LifePost, comment: LifeComment) {
}
}
function markCommentDeleted(items: LifeComment[], id: number): boolean {
function countCommentBranch(comment: LifeComment): number {
return 1 + comment.replies.reduce((total, reply) => total + countCommentBranch(reply), 0);
}
function removeCommentFromTree(items: LifeComment[], id: number): number {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (!item) {
continue;
}
if (item.id === id) {
const removedCount = countCommentBranch(item);
items.splice(index, 1);
return removedCount;
}
const removedCount = removeCommentFromTree(item.replies, id);
if (removedCount > 0) {
return removedCount;
}
}
return 0;
}
function markOwnCommentDeleted(items: LifeComment[], id: number): boolean {
for (const item of items) {
if (item.id === id) {
item.deleted = true;
item.body = '';
item.author = null;
return true;
}
if (markCommentDeleted(item.replies, id)) {
if (markOwnCommentDeleted(item.replies, id)) {
return true;
}
}
@@ -581,7 +632,19 @@ async function deleteComment(comment: LifeComment) {
try {
await api.deleteLifeComment(comment.id);
markCommentDeleted(comments.value, comment.id);
if (currentUser.value?.id === comment.author?.id) {
markOwnCommentDeleted(comments.value, comment.id);
comments.value = [...comments.value];
} else {
const removedCount = removeCommentFromTree(comments.value, comment.id);
if (removedCount > 0) {
comments.value = [...comments.value];
commentsTotal.value = Math.max(0, commentsTotal.value - removedCount);
if (post.value) {
post.value.commentCount = commentsTotal.value;
}
}
}
if (replyTargetId.value === comment.id) {
cancelReply(comment.id);
}
@@ -590,8 +653,24 @@ async function deleteComment(comment: LifeComment) {
}
}
async function restoreComment(comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const restored = await api.restoreLifeComment(comment.id);
replaceCommentInTree(comments.value, restored);
comments.value = [...comments.value];
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.restoreCommentFailed'));
} finally {
commentBusyKey.value = '';
}
}
function commentAuthorName(comment: LifeComment) {
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
return comment.author?.displayName ?? t('pages.life.byUnknown');
}
function commentInitial(comment: LifeComment) {
@@ -781,7 +860,12 @@ onUnmounted(() => {
</div>
<div class="life-post__review-actions">
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
<StatusBadge
v-if="moderationStatusVisible(post.moderationStatus)"
:label="moderationLabel(post.moderationStatus)"
:tone="moderationTone(post.moderationStatus)"
compact
/>
<button
v-if="canRetryModeration(post)"
class="life-icon-button life-review-button"
@@ -889,7 +973,7 @@ onUnmounted(() => {
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
<RouterLink v-if="comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
{{ comment.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(comment) }}</strong>
@@ -901,7 +985,7 @@ onUnmounted(() => {
compact
/>
</div>
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
<p class="life-comment__body">{{ comment.body }}</p>
<p
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
class="life-moderation-detail life-moderation-detail--comment"
@@ -910,9 +994,9 @@ onUnmounted(() => {
<span>{{ comment.moderationReason }}</span>
</p>
<div v-if="!comment.deleted" class="life-comment__actions">
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
<button
v-if="canCommentOnPost"
v-if="!comment.deleted && canCommentOnPost"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.reply')"
@@ -931,6 +1015,17 @@ onUnmounted(() => {
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
</button>
<button
v-if="canRestoreComment(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.restoreComment')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="restoreComment(comment)"
>
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button>
</div>
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
@@ -977,7 +1072,7 @@ onUnmounted(() => {
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
<RouterLink v-if="reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
{{ reply.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(reply) }}</strong>
@@ -989,7 +1084,7 @@ onUnmounted(() => {
compact
/>
</div>
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
<p class="life-comment__body">{{ reply.body }}</p>
<p
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
class="life-moderation-detail life-moderation-detail--comment"
@@ -997,8 +1092,9 @@ onUnmounted(() => {
<strong>{{ t('pages.life.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span>
</p>
<div v-if="canManageComment(reply)" class="life-comment__actions">
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="canManageComment(reply)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('pages.life.deleteComment')"
@@ -1007,6 +1103,17 @@ onUnmounted(() => {
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
</button>
<button
v-if="canRestoreComment(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.restoreComment')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="restoreComment(reply)"
>
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button>
</div>
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(reply.id)] }}

View File

@@ -28,6 +28,7 @@ import {
iconReply,
iconSave,
iconSearch,
iconUndo,
iconVersion,
iconWarning
} from '../icons';
@@ -60,6 +61,7 @@ type LifeCommentPageState = {
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type LifeFeedScope = 'all' | 'following';
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
@@ -78,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('');
@@ -180,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) : '';
@@ -195,6 +202,7 @@ async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
activeFeedScope.value = 'all';
authReady.value = true;
return;
}
@@ -204,6 +212,7 @@ async function loadCurrentUser() {
currentUser.value = response.user;
} catch {
currentUser.value = null;
activeFeedScope.value = 'all';
setAuthToken(null);
} finally {
authReady.value = true;
@@ -264,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,
@@ -272,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;
}
@@ -308,7 +318,7 @@ async function loadMorePosts() {
loadError.value = '';
try {
const page = await api.lifePosts({
const params = {
cursor,
limit: lifePostPageSize,
search: searchQuery.value,
@@ -317,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;
}
@@ -446,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];
@@ -478,8 +489,12 @@ function canManageComment(comment: LifeComment) {
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
}
function canRestoreComment(comment: LifeComment) {
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
}
function canSeeCommentModeration(comment: LifeComment) {
return currentUser.value?.id === comment.author?.id || can('life.comments.delete-any');
return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'));
}
function commentKey(postId: number) {
@@ -579,6 +594,10 @@ function moderationTone(status: AiModerationStatus) {
return tones[status];
}
function moderationStatusVisible(status: AiModerationStatus) {
return status !== 'approved';
}
function canRetryModeration(post: LifePost) {
return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post);
}
@@ -714,6 +733,25 @@ function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
}
function replaceCommentInTree(comments: LifeComment[], updated: LifeComment): boolean {
for (let index = 0; index < comments.length; index += 1) {
const comment = comments[index];
if (!comment) {
continue;
}
if (comment.id === updated.id) {
comments[index] = { ...updated, replies: comment.replies };
return true;
}
if (replaceCommentInTree(comment.replies, updated)) {
return true;
}
}
return false;
}
async function loadComments(post: LifePost, reset = false) {
const existing = commentPage(post);
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
@@ -774,7 +812,7 @@ function isRatingBusy(postId: number) {
}
function commentAuthorName(comment: LifeComment) {
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
return comment.author?.displayName ?? t('pages.life.byUnknown');
}
function commentInitial(comment: LifeComment) {
@@ -1019,16 +1057,39 @@ async function submitReply(post: LifePost, comment: LifeComment) {
}
}
function markCommentDeleted(comments: LifeComment[], id: number): boolean {
function countCommentTree(comment: LifeComment): number {
return 1 + comment.replies.reduce((total, reply) => total + countCommentTree(reply), 0);
}
function removeCommentFromTree(comments: LifeComment[], id: number): number {
for (let index = 0; index < comments.length; index += 1) {
const comment = comments[index];
if (!comment) {
continue;
}
if (comment.id === id) {
const removedCount = countCommentTree(comment);
comments.splice(index, 1);
return removedCount;
}
const removedCount = removeCommentFromTree(comment.replies, id);
if (removedCount > 0) {
return removedCount;
}
}
return 0;
}
function markOwnCommentDeleted(comments: LifeComment[], id: number): boolean {
for (const comment of comments) {
if (comment.id === id) {
comment.deleted = true;
comment.body = '';
comment.author = null;
return true;
}
if (markCommentDeleted(comment.replies, id)) {
if (markOwnCommentDeleted(comment.replies, id)) {
return true;
}
}
@@ -1046,7 +1107,24 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
try {
await api.deleteLifeComment(comment.id);
markCommentDeleted(commentsForPost(post), comment.id);
if (currentUser.value?.id === comment.author?.id) {
markOwnCommentDeleted(commentsForPost(post), comment.id);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
} else {
const removedCount = removeCommentFromTree(commentsForPost(post), comment.id);
if (removedCount > 0) {
const nextTotal = Math.max(0, commentCount(post) - removedCount);
post.commentCount = nextTotal;
updateCommentPage(post, (page) => ({
...page,
items: [...page.items],
total: nextTotal
}));
}
}
if (replyTargetId.value === comment.id) {
cancelReply(comment.id);
}
@@ -1055,6 +1133,25 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
}
}
async function restoreComment(post: LifePost, comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const restored = await api.restoreLifeComment(comment.id);
replaceCommentInTree(commentsForPost(post), restored);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.restoreCommentFailed'));
} finally {
commentBusyKey.value = '';
}
}
function formatPostTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -1118,6 +1215,9 @@ watch(activeRateableFilter, () => {
watch(activeSort, () => {
void loadPosts();
});
watch(activeFeedScope, () => {
void loadPosts();
});
watch(locale, () => {
void loadLanguages();
void loadLifeCategories();
@@ -1133,8 +1233,10 @@ onMounted(() => {
void loadLifeCategories();
void loadPosts();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
void loadPosts();
void (async () => {
await loadCurrentUser();
await loadPosts();
})();
});
});
@@ -1295,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')" />
@@ -1451,7 +1560,12 @@ onUnmounted(() => {
</button>
<div class="life-post__review-actions">
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
<StatusBadge
v-if="moderationStatusVisible(post.moderationStatus)"
:label="moderationLabel(post.moderationStatus)"
:tone="moderationTone(post.moderationStatus)"
compact
/>
<button
v-if="canRetryModeration(post)"
class="life-icon-button life-review-button"
@@ -1573,7 +1687,7 @@ onUnmounted(() => {
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
<RouterLink v-if="comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
{{ comment.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(comment) }}</strong>
@@ -1585,7 +1699,7 @@ onUnmounted(() => {
compact
/>
</div>
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
<p class="life-comment__body">{{ comment.body }}</p>
<p
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
class="life-moderation-detail life-moderation-detail--comment"
@@ -1594,9 +1708,9 @@ onUnmounted(() => {
<span>{{ comment.moderationReason }}</span>
</p>
<div v-if="!comment.deleted" class="life-comment__actions">
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
<button
v-if="canComment"
v-if="!comment.deleted && canComment"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.reply')"
@@ -1615,6 +1729,17 @@ onUnmounted(() => {
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
</button>
<button
v-if="canRestoreComment(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.restoreComment')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="restoreComment(post, comment)"
>
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button>
</div>
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
@@ -1661,7 +1786,7 @@ onUnmounted(() => {
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
<RouterLink v-if="reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
{{ reply.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(reply) }}</strong>
@@ -1673,7 +1798,7 @@ onUnmounted(() => {
compact
/>
</div>
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
<p class="life-comment__body">{{ reply.body }}</p>
<p
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
class="life-moderation-detail life-moderation-detail--comment"
@@ -1681,8 +1806,9 @@ onUnmounted(() => {
<strong>{{ t('pages.life.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span>
</p>
<div v-if="canManageComment(reply)" class="life-comment__actions">
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="canManageComment(reply)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('pages.life.deleteComment')"
@@ -1691,6 +1817,17 @@ onUnmounted(() => {
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
</button>
<button
v-if="canRestoreComment(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.restoreComment')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="restoreComment(post, reply)"
>
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button>
</div>
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(reply.id)] }}

View File

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

View File

@@ -76,6 +76,24 @@ export const systemWordingMessages = {
logout: 'Log out',
register: 'Register'
},
search: {
label: 'Search Pokopia Wiki',
placeholder: 'Search wiki',
open: 'Open search',
clear: 'Clear search',
empty: 'No matching results',
failed: 'Search is unavailable',
groups: {
pokemon: 'Pokemon',
habitats: 'Habitats',
items: 'Items',
ancientArtifacts: 'Ancient Artifacts',
recipes: 'Recipes',
dailyChecklist: 'Daily CheckList',
life: 'Life',
users: 'Users'
}
},
notifications: {
title: 'Notifications',
open: 'Open notifications',
@@ -89,10 +107,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}'
@@ -487,6 +507,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',
@@ -831,6 +859,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',
@@ -892,10 +923,12 @@ export const systemWordingMessages = {
deleteComment: 'Delete comment',
deleteCommentConfirm: 'Delete this comment?',
commentDeleted: 'Comment deleted',
restoreComment: 'Undo',
commentRequired: 'Please enter a comment.',
commentFailed: 'Comment failed',
replyFailed: 'Reply failed',
deleteCommentFailed: 'Delete comment failed',
restoreCommentFailed: 'Undo failed',
publish: 'Post',
publishing: 'Posting',
update: 'Update',
@@ -1178,6 +1211,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: {
@@ -1362,6 +1396,24 @@ export const systemWordingMessages = {
logout: '退出',
register: '注册'
},
search: {
label: '搜索 Pokopia Wiki',
placeholder: '搜索 Wiki',
open: '打开搜索',
clear: '清空搜索',
empty: '没有匹配结果',
failed: '搜索暂不可用',
groups: {
pokemon: 'Pokemon',
habitats: '栖息地',
items: '物品',
ancientArtifacts: 'Ancient Artifacts',
recipes: '材料单',
dailyChecklist: '每日 CheckList',
life: 'Life',
users: '用户'
}
},
notifications: {
title: '通知',
open: '打开通知',
@@ -1375,10 +1427,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}审核失败'
@@ -1747,6 +1801,14 @@ export const systemWordingMessages = {
passwordSaved: '密码已更新',
passwordSaveFailed: '密码更新失败',
savePassword: '保存密码',
follow: '关注',
followBack: '回关',
following: '已关注',
friend: '好友',
followers: '粉丝',
followingCount: '关注',
friends: '好友',
followFailed: '关注操作失败',
joinedAt: '加入于 {date}',
lifePosts: 'Life 动态',
lifeComments: 'Life 评论',
@@ -2091,6 +2153,9 @@ export const systemWordingMessages = {
languages: '语言区',
allLanguages: '全部语言',
allCategories: '全部',
feedScope: '动态范围',
allFeed: '全部动态',
followingFeed: '关注动态',
allVersions: '全部版本',
versionFilter: '版本',
ratingFilter: '评分',
@@ -2152,10 +2217,12 @@ export const systemWordingMessages = {
deleteComment: '删除评论',
deleteCommentConfirm: '确认删除这条评论?',
commentDeleted: '评论已删除',
restoreComment: '撤销',
commentRequired: '请输入评论内容。',
commentFailed: '评论失败',
replyFailed: '回复失败',
deleteCommentFailed: '删除评论失败',
restoreCommentFailed: '撤销失败',
publish: '发布',
publishing: '发布中',
update: '更新',
@@ -2438,6 +2505,7 @@ export const systemWordingMessages = {
invalidResetToken: '密码重置链接无效或已过期',
currentPasswordInvalid: '当前密码不正确',
invalidReferralCode: '邀请码无效',
cannotFollowSelf: '不能关注自己',
emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。'
},
validation: {