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
This commit is contained in:
2026-05-03 13:52:35 +08:00
parent 0e835f9c03
commit 282481bbcc
8 changed files with 453 additions and 23 deletions

View File

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

View File

@@ -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<string,
emailVerified: 'server.auth.emailVerified',
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
passwordResetComplete: 'server.auth.passwordResetComplete',
passwordChanged: 'server.auth.passwordChanged',
invalidCredentials: 'server.auth.invalidCredentials',
verifyEmailFirst: 'server.auth.verifyEmailFirst',
invalidResetToken: 'server.auth.invalidResetToken',
currentPasswordInvalid: 'server.auth.currentPasswordInvalid',
invalidReferralCode: 'server.auth.invalidReferralCode',
emailSubject: 'email.auth.verificationSubject',
emailHtml: 'email.auth.verificationHtml',
@@ -1007,6 +1011,40 @@ export async function updateCurrentUser(
return (await publicUserById(user.id)) ?? toPublicUser(user);
}
export async function changeCurrentUserPassword(
userId: number,
payload: Record<string, unknown>,
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<LoginUserRow>(
'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<ReferralSummary> {
return withTransaction(async (client) => {
const code = await ensureReferralCode(client, userId);

View File

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

View File

@@ -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<string, unknown>) : {};
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;

View File

@@ -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<UserReactionActivityPage>(
`/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<UserCommentActivityPage>(
`/api/users/${id}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit
limit: params.limit,
source: params.source
})}`
),
adminUsers: () => getJson<AdminUser[]>('/api/admin/users'),

View File

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

View File

@@ -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<AuthUser | null>(null);
const profile = ref<PublicUserProfile | null>(null);
const referral = ref<ReferralSummary | null>(null);
const displayName = ref('');
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const activeTab = ref<ProfileTab>('feeds');
const contributionFilter = ref<ContributionFilter>('all');
const reactionFilter = ref<ReactionFilter>('all');
const commentFilter = ref<CommentFilter>('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<LifePost[]>([]);
@@ -98,6 +123,27 @@ const tabs = computed<TabOption[]>(() => {
return canShowAccount.value ? [...baseTabs, { value: 'account', label: t('pages.profile.tabAccount') }] : baseTabs;
});
const contributionFilterTabs = computed<TabOption[]>(() => [
{ 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<TabOption[]>(() => [
{ 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<TabOption[]>(() => [
{ 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<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(value);
@@ -289,21 +411,31 @@ function writeClipboard(value: string): Promise<void> {
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);
if (surface === 'summary') {
referralSummaryMessage.value = t('pages.profile.referralCopied');
} else {
referralMessage.value = t('pages.profile.referralCopied');
}
} catch {
if (surface === 'summary') {
referralSummaryErrorMessage.value = t('pages.profile.referralCopyFailed');
} else {
referralErrorMessage.value = t('pages.profile.referralCopyFailed');
}
}
}
async function loadActiveTab(force = false) {
if (!profile.value) {
@@ -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<string, string> = {
pokemon: t('nav.pokemon'),
@@ -534,6 +674,19 @@ onMounted(() => {
<dd>{{ item.value }}</dd>
</div>
</dl>
<div v-if="canShowAccount && referral" class="profile-referral-summary">
<div>
<span>{{ t('pages.profile.referralCode') }}</span>
<strong>{{ referral.code }}</strong>
</div>
<button class="ui-button ui-button--blue" type="button" @click="copyReferralLink('summary')">
<Icon :icon="iconCopy" class="ui-icon" aria-hidden="true" />
{{ t('pages.profile.copyReferralLink') }}
</button>
<StatusMessage v-if="referralSummaryMessage" variant="success">{{ referralSummaryMessage }}</StatusMessage>
<StatusMessage v-if="referralSummaryErrorMessage" variant="danger">{{ referralSummaryErrorMessage }}</StatusMessage>
</div>
</section>
<Tabs id="profile-tabs" v-model="activeTab" :tabs="tabs" :label="t('pages.profile.tabsLabel')" />
@@ -631,14 +784,22 @@ onMounted(() => {
</section>
</div>
<Tabs
id="profile-contribution-filter"
v-model="contributionFilter"
class="profile-secondary-tabs"
:tabs="contributionFilterTabs"
:label="t('pages.profile.contributionFiltersLabel')"
/>
<section class="profile-card profile-card--wide" :aria-label="t('pages.profile.contributionBreakdown')">
<div class="profile-card__header">
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
<h2>{{ t('pages.profile.contributionBreakdown') }}</h2>
</div>
<div v-if="profile.contributions.length" class="profile-contribution-list">
<article v-for="item in profile.contributions" :key="item.contentType" class="profile-contribution-row">
<div v-if="filteredContributions.length" class="profile-contribution-list">
<article v-for="item in filteredContributions" :key="item.contentType" class="profile-contribution-row">
<div>
<strong>{{ contentTypeLabel(item.contentType) }}</strong>
<span v-if="item.lastContributedAt">{{ formatDateTime(item.lastContributedAt) }}</span>
@@ -666,7 +827,7 @@ onMounted(() => {
<div v-else class="profile-empty profile-empty--compact">
<Icon :icon="iconProfile" class="profile-empty__icon" aria-hidden="true" />
<h2>{{ t('pages.profile.contributionsEmpty') }}</h2>
<h2>{{ profile.contributions.length ? t('pages.profile.contributionsFilterEmpty') : t('pages.profile.contributionsEmpty') }}</h2>
</div>
</section>
</section>
@@ -674,6 +835,14 @@ onMounted(() => {
<section v-else-if="activeTab === 'reactions'" class="profile-tab-panel" :aria-label="t('pages.profile.tabReactions')">
<StatusMessage v-if="reactionsError" variant="danger" :duration="0">{{ reactionsError }}</StatusMessage>
<Tabs
id="profile-reaction-filter"
v-model="reactionFilter"
class="profile-secondary-tabs"
:tabs="reactionFilterTabs"
:label="t('pages.profile.reactionFiltersLabel')"
/>
<div v-if="reactionsLoading && !reactions.length" class="profile-activity-list" aria-hidden="true">
<article v-for="index in 3" :key="index" class="profile-activity-card">
<Skeleton width="180px" />
@@ -713,13 +882,21 @@ onMounted(() => {
<div v-else class="profile-empty">
<Icon :icon="iconReactionLike" class="profile-empty__icon" aria-hidden="true" />
<h2>{{ t('pages.profile.reactionsEmpty') }}</h2>
<h2>{{ reactionFilter === 'all' ? t('pages.profile.reactionsEmpty') : t('pages.profile.reactionsFilterEmpty') }}</h2>
</div>
</section>
<section v-else-if="activeTab === 'comments'" class="profile-tab-panel" :aria-label="t('pages.profile.tabComments')">
<StatusMessage v-if="commentsError" variant="danger" :duration="0">{{ commentsError }}</StatusMessage>
<Tabs
id="profile-comment-filter"
v-model="commentFilter"
class="profile-secondary-tabs"
:tabs="commentFilterTabs"
:label="t('pages.profile.commentFiltersLabel')"
/>
<div v-if="commentsLoading && !comments.length" class="profile-activity-list" aria-hidden="true">
<article v-for="index in 3" :key="index" class="profile-activity-card">
<Skeleton width="180px" />
@@ -754,7 +931,7 @@ onMounted(() => {
<div v-else class="profile-empty">
<Icon :icon="iconComment" class="profile-empty__icon" aria-hidden="true" />
<h2>{{ t('pages.profile.commentsEmpty') }}</h2>
<h2>{{ commentFilter === 'all' ? t('pages.profile.commentsEmpty') : t('pages.profile.commentsFilterEmpty') }}</h2>
</div>
</section>
@@ -815,7 +992,7 @@ onMounted(() => {
<label for="profile-referral-url">{{ t('pages.profile.referralUrl') }}</label>
<div class="profile-referral-link-row">
<input id="profile-referral-url" class="profile-readonly-input" :value="referral.url" readonly />
<button class="ui-button ui-button--blue" type="button" @click="copyReferralLink">
<button class="ui-button ui-button--blue" type="button" @click="copyReferralLink()">
<Icon :icon="iconCopy" class="ui-icon" aria-hidden="true" />
{{ t('pages.profile.copyReferralLink') }}
</button>
@@ -829,6 +1006,62 @@ onMounted(() => {
<StatusMessage v-else-if="referralErrorMessage" variant="danger" :duration="0">{{ referralErrorMessage }}</StatusMessage>
</section>
<section class="profile-card profile-card--password" :aria-label="t('pages.profile.passwordTitle')">
<div class="profile-card__header">
<Icon :icon="iconKey" class="profile-card__icon" aria-hidden="true" />
<h2>{{ t('pages.profile.passwordTitle') }}</h2>
</div>
<form class="auth-form" @submit.prevent="savePassword">
<div class="field">
<label for="profile-current-password">{{ t('auth.currentPassword') }}</label>
<input
id="profile-current-password"
v-model="currentPassword"
autocomplete="current-password"
required
:disabled="passwordBusy"
type="password"
/>
</div>
<div class="field">
<label for="profile-new-password">{{ t('auth.newPassword') }}</label>
<input
id="profile-new-password"
v-model="newPassword"
autocomplete="new-password"
minlength="8"
required
:disabled="passwordBusy"
type="password"
/>
<small class="profile-field-note">{{ t('pages.profile.passwordHint') }}</small>
</div>
<div class="field">
<label for="profile-confirm-password">{{ t('auth.confirmPassword') }}</label>
<input
id="profile-confirm-password"
v-model="confirmPassword"
autocomplete="new-password"
minlength="8"
required
:disabled="passwordBusy"
type="password"
/>
</div>
<StatusMessage v-if="passwordMessage" variant="success">{{ passwordMessage }}</StatusMessage>
<StatusMessage v-if="passwordErrorMessage" variant="danger">{{ passwordErrorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="passwordBusy" type="submit">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ passwordBusy ? t('common.saving') : t('pages.profile.savePassword') }}
</button>
</form>
</section>
</section>
</div>
</section>

View File

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