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: {