From 282481bbcc34415d049aa15b22e638548fd0d164 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 3 May 2026 13:52:35 +0800 Subject: [PATCH] feat(profile): add password change and activity filters Implement password change API and UI in the Account tab Add secondary filters for contributions, reactions, and comments Display referral summary in the profile header --- DESIGN.md | 8 +- backend/src/auth.ts | 38 ++++ backend/src/queries.ts | 43 ++++- backend/src/server.ts | 13 ++ frontend/src/services/api.ts | 19 +- frontend/src/styles/main.css | 60 +++++- frontend/src/views/UserProfileView.vue | 257 +++++++++++++++++++++++-- system-wordings.ts | 38 +++- 8 files changed, 453 insertions(+), 23 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 68d2c5e..55923f0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -120,8 +120,12 @@ - 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。 - 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。 - Profile 使用 Tabs 组织:Feeds、Contributions、Reactions、Comments;仅自己的 `/profile` 额外展示 Account。 + - Contributions、Reactions、Comments 在对应 Tab 内提供二级分类:Contributions 可按主要内容类型或配置类查看,Reactions 可按 reaction 类型查看,Comments 可按 Life / Wiki discussion 来源查看。 - 公开用户摘要只包含 `id`、`displayName` 和公开展示需要的加入时间;不公开邮箱、角色、权限、Referral Code、邀请链接、session、token/hash 或内部审计 payload。 - - 当前版本只允许用户在自己的 `/profile` Account Tab 更新 `displayName`,不支持头像、邮箱修改或直接密码修改。 + - 当前用户可在自己的 `/profile` Account Tab 更新 `displayName`、查看 Referral 信息、复制 Referral 邀请链接,并修改密码;当前版本不支持头像或邮箱修改。 + - 当前用户自己的 Profile 顶部摘要区可显示简化 Referral Code 和 Copy Link 入口;完整 Referral 卡片保留 Referral Code、邀请链接复制入口和有效邀请数量;这些字段不在公开 Profile 展示。 + - 修改密码必须提交当前密码和新密码;成功后更新 password hash、作废未使用的密码重置 token,并保留当前 session、删除该用户其他 session。 + - 修改密码 API 只返回本地化结果 message,不返回 user、session、token/hash 或内部审计 payload。 - 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。 - 显示名用于编辑署名、讨论和 Life 内容作者展示。 @@ -178,7 +182,7 @@ - 全局唯一。 - 只包含大写英文字母和数字。 - 现有用户在首次读取 Referral 信息或重新注册未验证账号时自动补齐。 -- 登录用户可在 `/profile` 查看自己的 Referral Code、邀请链接和有效邀请数量。 +- 登录用户可在 `/profile` Account Tab 查看自己的 Referral Code、邀请链接复制入口和有效邀请数量。 - 邀请链接使用前端注册页路径:`/register?ref=CODE`。 - 注册页支持: - 从 `ref` query 自动填入 Referral Code。 diff --git a/backend/src/auth.ts b/backend/src/auth.ts index bf7b699..1d4df45 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -53,9 +53,11 @@ type AuthMessageKey = | 'emailVerified' | 'checkPasswordResetEmail' | 'passwordResetComplete' + | 'passwordChanged' | 'invalidCredentials' | 'verifyEmailFirst' | 'invalidResetToken' + | 'currentPasswordInvalid' | 'invalidReferralCode' | 'emailSubject' | 'emailHtml' @@ -171,9 +173,11 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record, + currentSessionToken: string, + locale = defaultLocale +): Promise<{ message: string }> { + const currentPassword = typeof payload.currentPassword === 'string' ? payload.currentPassword : ''; + const nextPassword = await cleanPassword(payload.password, locale); + + if (!currentPassword) { + throw statusError(await authMessage(locale, 'currentPasswordInvalid'), 400); + } + + const user = await queryOne( + 'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE id = $1', + [userId] + ); + + if (!user || !(await verifyPassword(currentPassword, user.password_hash))) { + throw statusError(await authMessage(locale, 'currentPasswordInvalid'), 400); + } + + const passwordHash = await hashPassword(nextPassword); + const currentSessionHash = hashToken(currentSessionToken); + + await withTransaction(async (client) => { + await client.query('UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2', [passwordHash, user.id]); + await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [user.id]); + await client.query('DELETE FROM user_sessions WHERE user_id = $1 AND token_hash <> $2', [user.id, currentSessionHash]); + }); + + return { message: await authMessage(locale, 'passwordChanged') }; +} + export async function getReferralSummary(userId: number): Promise { return withTransaction(async (client) => { const code = await ensureReferralCode(client, userId); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index b70a0ae..87f6e6e 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -2205,6 +2205,28 @@ function cleanLifeReactionType(value: unknown): LifeReactionType { return value; } +function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null { + const reactionType = asString(value); + if (!reactionType) { + return null; + } + + return cleanLifeReactionType(reactionType); +} + +function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null { + const source = asString(value); + if (!source) { + return null; + } + + if (source !== 'life' && source !== 'discussion') { + throw validationError('server.validation.invalidField'); + } + + return source; +} + function lifePostProjection(locale = defaultLocale): string { const tagName = localizedName('life-tags', 'lt', locale); @@ -2691,9 +2713,15 @@ export async function listUserReactionActivities( const cursor = decodeLifePostCursor(paramsQuery.cursor); const limit = cleanLifePostLimit(paramsQuery.limit); + const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType); const params: unknown[] = [user.id]; const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL']; + if (reactionType) { + params.push(reactionType); + conditions.push(`lpr.reaction_type = $${params.length}`); + } + if (cursor) { params.push(cursor.createdAt, cursor.id); conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`); @@ -2765,19 +2793,28 @@ export async function listUserCommentActivities( const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor); const limit = cleanLifePostLimit(paramsQuery.limit); + const sourceFilter = cleanUserCommentActivitySourceFilter(paramsQuery.source); const pokemonName = localizedName('pokemon', 'p', locale); const itemName = localizedName('items', 'i', locale); const recipeItemName = localizedName('items', 'recipe_item', locale); const habitatName = localizedName('habitats', 'h', locale); const params: unknown[] = [user.id]; - let cursorClause = ''; + const outerConditions: string[] = []; + + if (sourceFilter) { + params.push(sourceFilter); + outerConditions.push(`source = $${params.length}`); + } if (cursor) { params.push(cursor.createdAt, cursor.source, cursor.id); - cursorClause = `WHERE (created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)`; + outerConditions.push( + `(created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)` + ); } params.push(limit + 1); + const outerWhere = outerConditions.length ? `WHERE ${outerConditions.join(' AND ')}` : ''; const rows = await query<{ id: number; source: UserCommentActivitySource; @@ -2849,7 +2886,7 @@ export async function listUserCommentActivities( target_title AS "targetTitle", target_excerpt AS "targetExcerpt" FROM activity - ${cursorClause} + ${outerWhere} ORDER BY created_at DESC, source DESC, id DESC LIMIT $${params.length} `, diff --git a/backend/src/server.ts b/backend/src/server.ts index 28c9f05..12164f9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,6 +5,7 @@ import Fastify from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { mkdir } from 'node:fs/promises'; import { + changeCurrentUserPassword, createPermission, createRole, deletePermission, @@ -301,6 +302,18 @@ app.patch('/api/auth/me', async (request, reply) => { return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) }; }); +app.patch('/api/auth/me/password', async (request, reply) => { + const token = getBearerToken(request.headers.authorization); + const user = token ? await getUserBySessionToken(token) : null; + + if (!user || !token) { + return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') }); + } + + const payload = request.body && typeof request.body === 'object' ? (request.body as Record) : {}; + return changeCurrentUserPassword(user.id, payload, token, requestLocale(request)); +}); + app.get('/api/auth/referral', async (request, reply) => { const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 50cb4fc..19aa786 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -355,9 +355,13 @@ export interface PublicUserProfile { contributions: PublicProfileContribution[]; } +export type ProfileCommentSource = 'life' | 'discussion'; + export interface ProfileActivityParams { cursor?: string | null; limit?: number; + reactionType?: LifeReactionType; + source?: ProfileCommentSource; } export interface UserReactionActivity { @@ -423,6 +427,11 @@ export interface UserProfilePayload { displayName: string; } +export interface ChangePasswordPayload { + currentPassword: string; + password: string; +} + export interface LoginPayload { email: string; password: string; @@ -559,7 +568,7 @@ export interface EntityDiscussionComment { export interface UserCommentActivity { id: number; - source: 'life' | 'discussion'; + source: ProfileCommentSource; body: string; createdAt: string; target: { @@ -760,6 +769,8 @@ export const api = { sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload), me: () => getJson<{ user: AuthUser }>('/api/auth/me'), updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload), + changePassword: (payload: ChangePasswordPayload) => + sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload), referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'), logout: () => postEmpty('/api/auth/logout'), publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`), @@ -774,14 +785,16 @@ export const api = { getJson( `/api/users/${id}/reactions${buildQuery({ cursor: params.cursor ?? undefined, - limit: params.limit + limit: params.limit, + reactionType: params.reactionType })}` ), userComments: (id: string | number, params: ProfileActivityParams = {}) => getJson( `/api/users/${id}/comments${buildQuery({ cursor: params.cursor ?? undefined, - limit: params.limit + limit: params.limit, + source: params.source })}` ), adminUsers: () => getJson('/api/admin/users'), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 8963e86..9ac7016 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -4376,6 +4376,10 @@ button:disabled, grid-column: 2; } +.profile-card--password { + grid-column: 1 / -1; +} + .profile-identity { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -4503,6 +4507,10 @@ button:disabled, min-width: 0; } +.profile-secondary-tabs .tab-list { + border-bottom-color: color-mix(in srgb, var(--line) 72%, transparent); +} + .profile-layout--loading { grid-template-columns: minmax(260px, 0.5fr) minmax(0, 1fr); } @@ -4566,6 +4574,50 @@ button:disabled, font-variant-numeric: tabular-nums; } +.profile-referral-summary { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.profile-referral-summary > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.profile-referral-summary span { + color: var(--muted); + font-size: 13px; + font-weight: 850; +} + +.profile-referral-summary strong { + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: 18px; + font-weight: 900; + overflow-wrap: anywhere; +} + +.profile-referral-summary .ui-button { + min-height: 44px; + white-space: nowrap; +} + +.profile-referral-summary .status-message { + position: static; + grid-column: 1 / -1; + box-shadow: none; +} + .profile-section-grid, .profile-account-grid { display: grid; @@ -5188,7 +5240,8 @@ button:disabled, grid-template-columns: 1fr; } - .profile-card--referral { + .profile-card--referral, + .profile-card--password { grid-column: auto; } @@ -5330,6 +5383,11 @@ button:disabled, grid-template-columns: 1fr; } + .profile-referral-summary { + grid-template-columns: 1fr; + } + + .profile-referral-summary .ui-button, .profile-referral-link-row .ui-button { width: 100%; } diff --git a/frontend/src/views/UserProfileView.vue b/frontend/src/views/UserProfileView.vue index abd61d2..9e8d2f3 100644 --- a/frontend/src/views/UserProfileView.vue +++ b/frontend/src/views/UserProfileView.vue @@ -11,6 +11,7 @@ import Tabs, { type TabOption } from '../components/Tabs.vue'; import { iconComment, iconCopy, + iconKey, iconLife, iconProfile, iconReactionFun, @@ -29,6 +30,7 @@ import { type DiscussionEntityType, type LifePost, type LifeReactionType, + type ProfileCommentSource, type PublicUserProfile, type ReferralSummary, type UserCommentActivity, @@ -36,6 +38,18 @@ import { } from '../services/api'; type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account'; +type PrimaryContributionFilter = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'daily-checklist'; +type ContributionFilter = 'all' | PrimaryContributionFilter | 'config'; +type ReactionFilter = 'all' | LifeReactionType; +type CommentFilter = 'all' | ProfileCommentSource; + +const primaryContributionFilters: PrimaryContributionFilter[] = [ + 'pokemon', + 'items', + 'recipes', + 'habitats', + 'daily-checklist' +]; const { locale, t } = useI18n(); const route = useRoute(); @@ -43,11 +57,22 @@ const currentUser = ref(null); const profile = ref(null); const referral = ref(null); const displayName = ref(''); +const currentPassword = ref(''); +const newPassword = ref(''); +const confirmPassword = ref(''); const activeTab = ref('feeds'); +const contributionFilter = ref('all'); +const reactionFilter = ref('all'); +const commentFilter = ref('all'); const loading = ref(true); const busy = ref(false); +const passwordBusy = ref(false); const message = ref(''); const errorMessage = ref(''); +const passwordMessage = ref(''); +const passwordErrorMessage = ref(''); +const referralSummaryMessage = ref(''); +const referralSummaryErrorMessage = ref(''); const referralMessage = ref(''); const referralErrorMessage = ref(''); const feeds = ref([]); @@ -98,6 +123,27 @@ const tabs = computed(() => { return canShowAccount.value ? [...baseTabs, { value: 'account', label: t('pages.profile.tabAccount') }] : baseTabs; }); +const contributionFilterTabs = computed(() => [ + { value: 'all', label: t('common.all') }, + { value: 'pokemon', label: t('nav.pokemon') }, + { value: 'items', label: t('nav.items') }, + { value: 'recipes', label: t('nav.recipes') }, + { value: 'habitats', label: t('nav.habitats') }, + { value: 'daily-checklist', label: t('nav.checklist') }, + { value: 'config', label: t('pages.profile.contributionConfig') } +]); +const reactionFilterTabs = computed(() => [ + { value: 'all', label: t('common.all') }, + { value: 'like', label: reactionLabel('like') }, + { value: 'helpful', label: reactionLabel('helpful') }, + { value: 'fun', label: reactionLabel('fun') }, + { value: 'thanks', label: reactionLabel('thanks') } +]); +const commentFilterTabs = computed(() => [ + { value: 'all', label: t('common.all') }, + { value: 'life', label: t('pages.profile.lifeCommentCategory') }, + { value: 'discussion', label: t('pages.profile.discussionCommentCategory') } +]); const headlineStats = computed(() => { const stats = profile.value?.stats; return [ @@ -126,6 +172,14 @@ const communityStats = computed(() => { { label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 } ]; }); +const filteredContributions = computed(() => { + const items = profile.value?.contributions ?? []; + if (contributionFilter.value === 'all') { + return items; + } + + return items.filter((item) => contributionCategory(item.contentType) === contributionFilter.value); +}); watch( tabs, @@ -144,6 +198,26 @@ watch( } ); +watch( + () => reactionFilter.value, + () => { + resetReactions(); + if (activeTab.value === 'reactions') { + void loadReactions(true); + } + } +); + +watch( + () => commentFilter.value, + () => { + resetComments(); + if (activeTab.value === 'comments') { + void loadComments(true); + } + } +); + watch( () => route.fullPath, () => { @@ -151,21 +225,33 @@ watch( } ); -function resetActivity() { +function resetFeeds() { feeds.value = []; feedsCursor.value = null; feedsHasMore.value = false; feedsError.value = ''; +} + +function resetReactions() { reactions.value = []; reactionsCursor.value = null; reactionsHasMore.value = false; reactionsError.value = ''; +} + +function resetComments() { comments.value = []; commentsCursor.value = null; commentsHasMore.value = false; commentsError.value = ''; } +function resetActivity() { + resetFeeds(); + resetReactions(); + resetComments(); +} + async function loadOptionalCurrentUser() { if (!getAuthToken()) { currentUser.value = null; @@ -188,10 +274,20 @@ async function loadProfile() { loading.value = true; message.value = ''; errorMessage.value = ''; + passwordMessage.value = ''; + passwordErrorMessage.value = ''; + referralSummaryMessage.value = ''; + referralSummaryErrorMessage.value = ''; referralMessage.value = ''; referralErrorMessage.value = ''; referral.value = null; profile.value = null; + contributionFilter.value = 'all'; + reactionFilter.value = 'all'; + commentFilter.value = 'all'; + currentPassword.value = ''; + newPassword.value = ''; + confirmPassword.value = ''; resetActivity(); try { @@ -271,6 +367,32 @@ async function saveProfile() { } } +async function savePassword() { + passwordMessage.value = ''; + passwordErrorMessage.value = ''; + + if (newPassword.value !== confirmPassword.value) { + passwordErrorMessage.value = t('auth.passwordMismatch'); + return; + } + + passwordBusy.value = true; + try { + const response = await api.changePassword({ + currentPassword: currentPassword.value, + password: newPassword.value + }); + currentPassword.value = ''; + newPassword.value = ''; + confirmPassword.value = ''; + passwordMessage.value = response.message || t('pages.profile.passwordSaved'); + } catch (error) { + passwordErrorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.passwordSaveFailed'); + } finally { + passwordBusy.value = false; + } +} + function writeClipboard(value: string): Promise { if (navigator.clipboard?.writeText) { return navigator.clipboard.writeText(value); @@ -289,19 +411,29 @@ function writeClipboard(value: string): Promise { return copied ? Promise.resolve() : Promise.reject(new Error('Clipboard unavailable')); } -async function copyReferralLink() { +async function copyReferralLink(surface: 'summary' | 'card' = 'card') { if (!referral.value) { return; } + referralSummaryMessage.value = ''; + referralSummaryErrorMessage.value = ''; referralMessage.value = ''; referralErrorMessage.value = ''; try { await writeClipboard(referral.value.url); - referralMessage.value = t('pages.profile.referralCopied'); + if (surface === 'summary') { + referralSummaryMessage.value = t('pages.profile.referralCopied'); + } else { + referralMessage.value = t('pages.profile.referralCopied'); + } } catch { - referralErrorMessage.value = t('pages.profile.referralCopyFailed'); + if (surface === 'summary') { + referralSummaryErrorMessage.value = t('pages.profile.referralCopyFailed'); + } else { + referralErrorMessage.value = t('pages.profile.referralCopyFailed'); + } } } @@ -351,7 +483,8 @@ async function loadReactions(reset = false) { try { const page = await api.userReactions(profile.value.user.id, { cursor: reset ? null : reactionsCursor.value, - limit: activityLimit + limit: activityLimit, + reactionType: reactionFilter.value === 'all' ? undefined : reactionFilter.value }); reactions.value = reset ? page.items : [...reactions.value, ...page.items]; reactionsCursor.value = page.nextCursor; @@ -373,7 +506,8 @@ async function loadComments(reset = false) { try { const page = await api.userComments(profile.value.user.id, { cursor: reset ? null : commentsCursor.value, - limit: activityLimit + limit: activityLimit, + source: commentFilter.value === 'all' ? undefined : commentFilter.value }); comments.value = reset ? page.items : [...comments.value, ...page.items]; commentsCursor.value = page.nextCursor; @@ -438,6 +572,12 @@ function reactionLabel(type: LifeReactionType): string { return t(`pages.life.reaction${type.charAt(0).toUpperCase()}${type.slice(1)}`); } +function contributionCategory(contentType: string): ContributionFilter { + return primaryContributionFilters.includes(contentType as PrimaryContributionFilter) + ? (contentType as PrimaryContributionFilter) + : 'config'; +} + function contentTypeLabel(contentType: string): string { const labels: Record = { pokemon: t('nav.pokemon'), @@ -534,6 +674,19 @@ onMounted(() => {
{{ item.value }}
+ +
+
+ {{ t('pages.profile.referralCode') }} + {{ referral.code }} +
+ + {{ referralSummaryMessage }} + {{ referralSummaryErrorMessage }} +
@@ -631,14 +784,22 @@ onMounted(() => { + +
-
-
+
+
{{ contentTypeLabel(item.contentType) }} {{ formatDateTime(item.lastContributedAt) }} @@ -666,7 +827,7 @@ onMounted(() => {
@@ -674,6 +835,14 @@ onMounted(() => {
{{ reactionsError }} + +
{{ commentsError }} + +
@@ -815,7 +992,7 @@ onMounted(() => { diff --git a/system-wordings.ts b/system-wordings.ts index ae944c1..903595b 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -68,6 +68,7 @@ export const systemWordingMessages = { accountAccess: 'Trainer Pass', email: 'Email', password: 'Password', + currentPassword: 'Current password', newPassword: 'New password', confirmPassword: 'Confirm password', displayName: 'Display name', @@ -117,7 +118,7 @@ export const systemWordingMessages = { pages: { profile: { title: 'User profile', - subtitle: 'Manage your account display name and email status.', + subtitle: 'Manage your account details, referral, and password.', loading: 'Loading profile', accountSummary: 'Account summary', profileDetails: 'Profile details', @@ -144,6 +145,20 @@ export const systemWordingMessages = { tabReactions: 'Reactions', tabComments: 'Comments', tabAccount: 'Account', + contributionFiltersLabel: 'Contribution categories', + contributionConfig: 'Config', + contributionsFilterEmpty: 'No contributions in this category', + reactionFiltersLabel: 'Reaction categories', + reactionsFilterEmpty: 'No reactions in this category', + commentFiltersLabel: 'Comment categories', + commentsFilterEmpty: 'No comments in this category', + lifeCommentCategory: 'Life', + discussionCommentCategory: 'Wiki', + passwordTitle: 'Change password', + passwordHint: 'Use at least 8 characters.', + passwordSaved: 'Password updated', + passwordSaveFailed: 'Password update failed', + savePassword: 'Save password', joinedAt: 'Joined {date}', lifePosts: 'Life posts', lifeComments: 'Life comments', @@ -662,9 +677,11 @@ export const systemWordingMessages = { emailVerified: 'Email verified', checkPasswordResetEmail: 'If an account uses this email, a password reset link will be sent.', passwordResetComplete: 'Password updated. You can log in with the new password.', + passwordChanged: 'Password updated.', invalidCredentials: 'Email or password is incorrect', verifyEmailFirst: 'Please complete email verification first', invalidResetToken: 'The password reset link is invalid or expired', + currentPasswordInvalid: 'Current password is incorrect', invalidReferralCode: 'Referral code is invalid' }, validation: { @@ -823,6 +840,7 @@ export const systemWordingMessages = { accountAccess: 'Trainer Pass', email: '邮箱', password: '密码', + currentPassword: '当前密码', newPassword: '新密码', confirmPassword: '确认密码', displayName: '显示名', @@ -872,7 +890,7 @@ export const systemWordingMessages = { pages: { profile: { title: '个人资料', - subtitle: '管理账号显示名和邮箱状态。', + subtitle: '管理账号资料、邀请信息和密码。', loading: '正在加载个人资料', accountSummary: '账号概览', profileDetails: '资料详情', @@ -899,6 +917,20 @@ export const systemWordingMessages = { tabReactions: '互动', tabComments: '评论', tabAccount: '账号', + contributionFiltersLabel: '贡献分类', + contributionConfig: '配置', + contributionsFilterEmpty: '该分类暂无贡献', + reactionFiltersLabel: '互动分类', + reactionsFilterEmpty: '该分类暂无互动', + commentFiltersLabel: '评论分类', + commentsFilterEmpty: '该分类暂无评论', + lifeCommentCategory: 'Life', + discussionCommentCategory: 'Wiki', + passwordTitle: '修改密码', + passwordHint: '至少使用 8 个字符。', + passwordSaved: '密码已更新', + passwordSaveFailed: '密码更新失败', + savePassword: '保存密码', joinedAt: '加入于 {date}', lifePosts: 'Life 动态', lifeComments: 'Life 评论', @@ -1417,9 +1449,11 @@ export const systemWordingMessages = { emailVerified: '邮箱已验证', checkPasswordResetEmail: '如果该邮箱已注册,系统会发送密码重置链接。', passwordResetComplete: '密码已更新,请使用新密码登录。', + passwordChanged: '密码已更新。', invalidCredentials: '邮箱或密码不正确', verifyEmailFirst: '请先完成邮箱验证', invalidResetToken: '密码重置链接无效或已过期', + currentPasswordInvalid: '当前密码不正确', invalidReferralCode: '邀请码无效' }, validation: {