feat(auth): add user profile page and display name update
Add PATCH /api/auth/me endpoint to update user display name Create UserProfileView for managing account details and email status Update AppShell sidebar to link authenticated user to profile page
This commit is contained in:
@@ -115,6 +115,11 @@
|
||||
- 对外用户字段只包含必要信息:
|
||||
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
||||
- 编辑署名:`id`、`displayName`
|
||||
- User Profile:
|
||||
- 登录用户可通过 `/profile` 查看自己的邮箱、邮箱验证状态和显示名。
|
||||
- 当前版本只允许用户更新自己的 `displayName`,不支持头像、公开个人主页、邮箱修改或直接密码修改。
|
||||
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
||||
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
|
||||
|
||||
## Community 编辑与审计
|
||||
|
||||
@@ -498,6 +503,7 @@ API 暴露边界:
|
||||
- UI 风格以 `DesignGuidelines.html` 为准。
|
||||
- 页面结构以 `AppShell`、`PageHeader`、列表、详情区和管理区为核心。
|
||||
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
||||
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
||||
- 页面级分类、筛选或辅助内容切换使用 Tabs,避免在内容页继续增加侧边栏。
|
||||
- 导航和主要操作使用图标增强识别。
|
||||
- 数据加载状态使用 Skeleton,避免裸文本 loading。
|
||||
@@ -544,6 +550,7 @@ API 暴露边界:
|
||||
- `POST /api/auth/request-password-reset`
|
||||
- `POST /api/auth/reset-password`
|
||||
- `GET /api/auth/me`
|
||||
- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。
|
||||
- `POST /api/auth/logout`
|
||||
|
||||
已验证用户编辑 API:
|
||||
|
||||
@@ -507,6 +507,29 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
|
||||
return user ? toPublicUser(user) : null;
|
||||
}
|
||||
|
||||
export async function updateCurrentUser(
|
||||
userId: number,
|
||||
payload: Record<string, unknown>,
|
||||
locale = defaultLocale
|
||||
): Promise<AuthUser> {
|
||||
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||
const user = await queryOne<UserRow>(
|
||||
`
|
||||
UPDATE users
|
||||
SET display_name = $1, updated_at = now()
|
||||
WHERE id = $2
|
||||
RETURNING id, email, display_name, email_verified_at
|
||||
`,
|
||||
[displayName, userId]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw statusError(await systemMessage(locale || defaultLocale, 'server.errors.loginRequired'), 401);
|
||||
}
|
||||
|
||||
return toPublicUser(user);
|
||||
}
|
||||
|
||||
export async function logoutSession(token: string): Promise<void> {
|
||||
if (token.length < 32) {
|
||||
return;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
registerUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
updateCurrentUser,
|
||||
verifyEmail,
|
||||
type AuthUser
|
||||
} from './auth.ts';
|
||||
@@ -198,6 +199,18 @@ app.get('/api/auth/me', async (request, reply) => {
|
||||
return { user };
|
||||
});
|
||||
|
||||
app.patch('/api/auth/me', async (request, reply) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
if (!user) {
|
||||
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 { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (request, reply) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
if (token) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { iconClose, iconLogin, iconLogout, iconMenu, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
||||
import { iconClose, iconLogin, iconLogout, iconMenu, iconProfile, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
||||
import type { AuthUser, Language } from '../services/api';
|
||||
import PokeBallMark from './PokeBallMark.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
@@ -184,7 +184,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="currentUser">
|
||||
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
|
||||
<RouterLink class="auth-user" to="/profile" :aria-label="t('nav.profile')" @click="closeSidebar">
|
||||
<Icon :icon="iconProfile" class="ui-icon auth-user__icon" aria-hidden="true" />
|
||||
<span class="auth-user__name">{{ currentUser.displayName || currentUser.email }}</span>
|
||||
</RouterLink>
|
||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
|
||||
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('nav.logout') }}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const iconMail: AppIcon = 'mdi:email-fast-outline';
|
||||
export const iconMenu: AppIcon = 'mdi:menu';
|
||||
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
||||
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
||||
export const iconProfile: AppIcon = 'mdi:account-circle-outline';
|
||||
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
||||
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||
export const iconReply: AppIcon = 'mdi:reply-outline';
|
||||
|
||||
@@ -13,6 +13,7 @@ import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import UserProfileView from '../views/UserProfileView.vue';
|
||||
import RegisterView from '../views/RegisterView.vue';
|
||||
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||
@@ -46,6 +47,7 @@ export const router = createRouter({
|
||||
{ path: '/checklist', component: DailyChecklistView },
|
||||
{ path: '/life', component: LifeView },
|
||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/forgot-password', component: ForgotPasswordView },
|
||||
{ path: '/reset-password', component: ResetPasswordView },
|
||||
@@ -60,7 +62,10 @@ export const router = createRouter({
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (!to.matched.some((record) => record.meta.requiresVerified === true)) {
|
||||
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true);
|
||||
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
||||
|
||||
if (!requiresAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -70,7 +75,7 @@ router.beforeEach(async (to) => {
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
return response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } };
|
||||
return !requiresVerified || response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } };
|
||||
} catch {
|
||||
setAuthToken(null);
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
|
||||
@@ -274,6 +274,10 @@ export interface AuthUser {
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfilePayload {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -449,9 +453,7 @@ export function setAuthToken(token: string | null, options: { persistent?: boole
|
||||
session?.removeItem(authTokenKey);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(authChangeEvent));
|
||||
}
|
||||
notifyAuthChange();
|
||||
}
|
||||
|
||||
export function onAuthTokenChange(callback: () => void): () => void {
|
||||
@@ -459,6 +461,12 @@ export function onAuthTokenChange(callback: () => void): () => void {
|
||||
return () => window.removeEventListener(authChangeEvent, callback);
|
||||
}
|
||||
|
||||
export function notifyAuthChange(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(authChangeEvent));
|
||||
}
|
||||
}
|
||||
|
||||
function requestHeaders(): HeadersInit {
|
||||
const token = getAuthToken();
|
||||
return {
|
||||
@@ -493,7 +501,7 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<T> {
|
||||
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
@@ -567,6 +575,7 @@ export const api = {
|
||||
resetPassword: (payload: { token: string; password: string }) =>
|
||||
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),
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
options: () => getJson<Options>('/api/options'),
|
||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||
|
||||
@@ -364,11 +364,43 @@ svg {
|
||||
}
|
||||
|
||||
.auth-user {
|
||||
min-height: 44px;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
padding: 8px 10px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft);
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
border-color 0.14s ease,
|
||||
color 0.14s ease;
|
||||
}
|
||||
|
||||
.auth-user:hover,
|
||||
.auth-user.router-link-active {
|
||||
border-color: var(--pokemon-blue);
|
||||
background: rgba(255, 203, 5, 0.22);
|
||||
color: var(--pokemon-blue-deep);
|
||||
}
|
||||
|
||||
.auth-user__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.auth-user__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -3830,6 +3862,103 @@ button:disabled,
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
||||
}
|
||||
|
||||
.profile-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.62fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-control);
|
||||
}
|
||||
|
||||
.profile-card--identity {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.profile-identity {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 3px solid var(--line-strong);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
linear-gradient(to bottom, var(--pokemon-red) 0 45%, var(--line-strong) 45% 55%, var(--pokeball-white) 55% 100%);
|
||||
color: var(--line-strong);
|
||||
box-shadow: inset 0 3px 0 rgba(255, 255, 255, 0.38), 0 3px 0 rgba(0, 0, 0, 0.16);
|
||||
font-family: var(--font-display);
|
||||
font-size: 23px;
|
||||
font-weight: 950;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-identity__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-identity h2,
|
||||
.profile-card__header h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 950;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.profile-identity p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.profile-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-card__icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.profile-field-note {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.profile-readonly-input {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||
@@ -4153,6 +4282,7 @@ button:disabled,
|
||||
.pokemon-profile-grid,
|
||||
.pokemon-profile-row,
|
||||
.pokemon-related-grid,
|
||||
.profile-layout,
|
||||
.system-wording-layout,
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
160
frontend/src/views/UserProfileView.vue
Normal file
160
frontend/src/views/UserProfileView.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconProfile, iconSave } from '../icons';
|
||||
import { api, notifyAuthChange, type AuthUser } from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const user = ref<AuthUser | null>(null);
|
||||
const displayName = ref('');
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
|
||||
const trimmedDisplayName = computed(() => displayName.value.trim());
|
||||
const hasChanges = computed(() => {
|
||||
const currentUser = user.value;
|
||||
if (!currentUser) return false;
|
||||
return trimmedDisplayName.value !== currentUser.displayName;
|
||||
});
|
||||
const profileInitial = computed(() => {
|
||||
const name = user.value?.displayName.trim() || user.value?.email.trim() || '';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
async function loadProfile() {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
user.value = response.user;
|
||||
displayName.value = response.user.displayName;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
|
||||
if (!trimmedDisplayName.value) {
|
||||
errorMessage.value = t('pages.profile.displayNameRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
try {
|
||||
const response = await api.updateMe({ displayName: trimmedDisplayName.value });
|
||||
user.value = response.user;
|
||||
displayName.value = response.user.displayName;
|
||||
message.value = t('pages.profile.saved');
|
||||
notifyAuthChange();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.saveFailed');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadProfile();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="profile-page">
|
||||
<PageHeader :title="t('pages.profile.title')" :subtitle="t('pages.profile.subtitle')">
|
||||
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="loading" class="profile-layout" aria-busy="true" :aria-label="t('pages.profile.loading')">
|
||||
<section class="profile-card profile-card--identity" aria-hidden="true">
|
||||
<div class="profile-identity">
|
||||
<Skeleton variant="box" width="58px" height="58px" />
|
||||
<div class="profile-identity__copy">
|
||||
<Skeleton width="160px" height="28px" />
|
||||
<Skeleton width="220px" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="profile-card" aria-hidden="true">
|
||||
<Skeleton width="180px" height="28px" />
|
||||
<div class="auth-form">
|
||||
<div class="field">
|
||||
<Skeleton width="110px" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Skeleton width="70px" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
<Skeleton variant="box" width="120px" height="42px" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="user" class="profile-layout">
|
||||
<section class="profile-card profile-card--identity" :aria-label="t('pages.profile.accountSummary')">
|
||||
<div class="profile-identity">
|
||||
<div class="profile-avatar" aria-hidden="true">{{ profileInitial }}</div>
|
||||
<div class="profile-identity__copy">
|
||||
<h2>{{ user.displayName }}</h2>
|
||||
<p>{{ user.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
:label="user.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified')"
|
||||
:tone="user.emailVerified ? 'success' : 'warning'"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="profile-card" :aria-label="t('pages.profile.profileDetails')">
|
||||
<div class="profile-card__header">
|
||||
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.profileDetails') }}</h2>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" @submit.prevent="saveProfile">
|
||||
<div class="field">
|
||||
<label for="profile-display-name">{{ t('auth.displayName') }}</label>
|
||||
<input
|
||||
id="profile-display-name"
|
||||
v-model="displayName"
|
||||
autocomplete="nickname"
|
||||
maxlength="40"
|
||||
required
|
||||
:disabled="busy"
|
||||
/>
|
||||
<small class="profile-field-note">{{ t('pages.profile.displayNameHint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="profile-email">{{ t('auth.email') }}</label>
|
||||
<input id="profile-email" class="profile-readonly-input" :value="user.email" readonly type="email" />
|
||||
</div>
|
||||
|
||||
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||
|
||||
<button class="ui-button ui-button--primary" :disabled="busy || !hasChanges" type="submit">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
|
||||
</section>
|
||||
</template>
|
||||
@@ -57,6 +57,7 @@ export const systemWordingMessages = {
|
||||
openMenu: 'Open navigation',
|
||||
closeMenu: 'Close navigation',
|
||||
language: 'Language',
|
||||
profile: 'Profile',
|
||||
login: 'Log in',
|
||||
logout: 'Log out',
|
||||
register: 'Register'
|
||||
@@ -108,6 +109,19 @@ export const systemWordingMessages = {
|
||||
completeEmailVerification: 'Please complete email verification first.'
|
||||
},
|
||||
pages: {
|
||||
profile: {
|
||||
title: 'User profile',
|
||||
subtitle: 'Manage your account display name and email status.',
|
||||
loading: 'Loading profile',
|
||||
accountSummary: 'Account summary',
|
||||
profileDetails: 'Profile details',
|
||||
displayNameHint: 'Display name is shown on edits, discussions, and Life posts.',
|
||||
displayNameRequired: 'Display name is required.',
|
||||
emailVerified: 'Email verified',
|
||||
emailUnverified: 'Email unverified',
|
||||
saved: 'Profile saved',
|
||||
saveFailed: 'Profile save failed'
|
||||
},
|
||||
pokemon: {
|
||||
title: 'Pokemon',
|
||||
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
||||
@@ -669,6 +683,7 @@ export const systemWordingMessages = {
|
||||
openMenu: '打开导航',
|
||||
closeMenu: '关闭导航',
|
||||
language: '语言',
|
||||
profile: '个人资料',
|
||||
login: '登录',
|
||||
logout: '退出',
|
||||
register: '注册'
|
||||
@@ -720,6 +735,19 @@ export const systemWordingMessages = {
|
||||
completeEmailVerification: '请先完成邮箱验证'
|
||||
},
|
||||
pages: {
|
||||
profile: {
|
||||
title: '个人资料',
|
||||
subtitle: '管理账号显示名和邮箱状态。',
|
||||
loading: '正在加载个人资料',
|
||||
accountSummary: '账号概览',
|
||||
profileDetails: '资料详情',
|
||||
displayNameHint: '显示名会用于编辑署名、讨论和 Life 动态。',
|
||||
displayNameRequired: '请输入显示名。',
|
||||
emailVerified: '邮箱已验证',
|
||||
emailUnverified: '邮箱未验证',
|
||||
saved: '个人资料已保存',
|
||||
saveFailed: '个人资料保存失败'
|
||||
},
|
||||
pokemon: {
|
||||
title: 'Pokemon',
|
||||
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
||||
|
||||
Reference in New Issue
Block a user