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`、`email`、`displayName`、`emailVerified`
|
||||||
- 编辑署名:`id`、`displayName`
|
- 编辑署名:`id`、`displayName`
|
||||||
|
- User Profile:
|
||||||
|
- 登录用户可通过 `/profile` 查看自己的邮箱、邮箱验证状态和显示名。
|
||||||
|
- 当前版本只允许用户更新自己的 `displayName`,不支持头像、公开个人主页、邮箱修改或直接密码修改。
|
||||||
|
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
||||||
|
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
|
||||||
|
|
||||||
## Community 编辑与审计
|
## Community 编辑与审计
|
||||||
|
|
||||||
@@ -498,6 +503,7 @@ API 暴露边界:
|
|||||||
- UI 风格以 `DesignGuidelines.html` 为准。
|
- UI 风格以 `DesignGuidelines.html` 为准。
|
||||||
- 页面结构以 `AppShell`、`PageHeader`、列表、详情区和管理区为核心。
|
- 页面结构以 `AppShell`、`PageHeader`、列表、详情区和管理区为核心。
|
||||||
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
||||||
|
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
||||||
- 页面级分类、筛选或辅助内容切换使用 Tabs,避免在内容页继续增加侧边栏。
|
- 页面级分类、筛选或辅助内容切换使用 Tabs,避免在内容页继续增加侧边栏。
|
||||||
- 导航和主要操作使用图标增强识别。
|
- 导航和主要操作使用图标增强识别。
|
||||||
- 数据加载状态使用 Skeleton,避免裸文本 loading。
|
- 数据加载状态使用 Skeleton,避免裸文本 loading。
|
||||||
@@ -544,6 +550,7 @@ API 暴露边界:
|
|||||||
- `POST /api/auth/request-password-reset`
|
- `POST /api/auth/request-password-reset`
|
||||||
- `POST /api/auth/reset-password`
|
- `POST /api/auth/reset-password`
|
||||||
- `GET /api/auth/me`
|
- `GET /api/auth/me`
|
||||||
|
- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。
|
||||||
- `POST /api/auth/logout`
|
- `POST /api/auth/logout`
|
||||||
|
|
||||||
已验证用户编辑 API:
|
已验证用户编辑 API:
|
||||||
|
|||||||
@@ -507,6 +507,29 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
|
|||||||
return user ? toPublicUser(user) : null;
|
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> {
|
export async function logoutSession(token: string): Promise<void> {
|
||||||
if (token.length < 32) {
|
if (token.length < 32) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
registerUser,
|
registerUser,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
|
updateCurrentUser,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
type AuthUser
|
type AuthUser
|
||||||
} from './auth.ts';
|
} from './auth.ts';
|
||||||
@@ -198,6 +199,18 @@ app.get('/api/auth/me', async (request, reply) => {
|
|||||||
return { user };
|
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) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getBearerToken(request.headers.authorization);
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
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 type { AuthUser, Language } from '../services/api';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
@@ -184,7 +184,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="currentUser">
|
<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">
|
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
|
||||||
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('nav.logout') }}
|
{{ t('nav.logout') }}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const iconMail: AppIcon = 'mdi:email-fast-outline';
|
|||||||
export const iconMenu: AppIcon = 'mdi:menu';
|
export const iconMenu: AppIcon = 'mdi:menu';
|
||||||
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
||||||
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
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 iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
||||||
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||||
export const iconReply: AppIcon = 'mdi:reply-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 AdminView from '../views/AdminView.vue';
|
||||||
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||||
import LoginView from '../views/LoginView.vue';
|
import LoginView from '../views/LoginView.vue';
|
||||||
|
import UserProfileView from '../views/UserProfileView.vue';
|
||||||
import RegisterView from '../views/RegisterView.vue';
|
import RegisterView from '../views/RegisterView.vue';
|
||||||
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||||
@@ -46,6 +47,7 @@ export const router = createRouter({
|
|||||||
{ path: '/checklist', component: DailyChecklistView },
|
{ path: '/checklist', component: DailyChecklistView },
|
||||||
{ path: '/life', component: LifeView },
|
{ path: '/life', component: LifeView },
|
||||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||||
|
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
|
||||||
{ path: '/login', component: LoginView },
|
{ path: '/login', component: LoginView },
|
||||||
{ path: '/forgot-password', component: ForgotPasswordView },
|
{ path: '/forgot-password', component: ForgotPasswordView },
|
||||||
{ path: '/reset-password', component: ResetPasswordView },
|
{ path: '/reset-password', component: ResetPasswordView },
|
||||||
@@ -60,7 +62,10 @@ export const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +75,7 @@ router.beforeEach(async (to) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.me();
|
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 {
|
} catch {
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
return { path: '/login', query: { redirect: to.fullPath } };
|
return { path: '/login', query: { redirect: to.fullPath } };
|
||||||
|
|||||||
@@ -274,6 +274,10 @@ export interface AuthUser {
|
|||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfilePayload {
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -449,9 +453,7 @@ export function setAuthToken(token: string | null, options: { persistent?: boole
|
|||||||
session?.removeItem(authTokenKey);
|
session?.removeItem(authTokenKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
notifyAuthChange();
|
||||||
window.dispatchEvent(new Event(authChangeEvent));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onAuthTokenChange(callback: () => void): () => void {
|
export function onAuthTokenChange(callback: () => void): () => void {
|
||||||
@@ -459,6 +461,12 @@ export function onAuthTokenChange(callback: () => void): () => void {
|
|||||||
return () => window.removeEventListener(authChangeEvent, callback);
|
return () => window.removeEventListener(authChangeEvent, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notifyAuthChange(): void {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event(authChangeEvent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function requestHeaders(): HeadersInit {
|
function requestHeaders(): HeadersInit {
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
return {
|
return {
|
||||||
@@ -493,7 +501,7 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|||||||
return response.json() as 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}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -567,6 +575,7 @@ export const api = {
|
|||||||
resetPassword: (payload: { token: string; password: string }) =>
|
resetPassword: (payload: { token: string; password: string }) =>
|
||||||
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||||
|
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
|
|||||||
@@ -364,11 +364,43 @@ svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-user {
|
.auth-user {
|
||||||
|
min-height: 44px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 850;
|
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;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -3830,6 +3862,103 @@ button:disabled,
|
|||||||
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
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 {
|
.admin-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||||
@@ -4153,6 +4282,7 @@ button:disabled,
|
|||||||
.pokemon-profile-grid,
|
.pokemon-profile-grid,
|
||||||
.pokemon-profile-row,
|
.pokemon-profile-row,
|
||||||
.pokemon-related-grid,
|
.pokemon-related-grid,
|
||||||
|
.profile-layout,
|
||||||
.system-wording-layout,
|
.system-wording-layout,
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
grid-template-columns: 1fr;
|
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',
|
openMenu: 'Open navigation',
|
||||||
closeMenu: 'Close navigation',
|
closeMenu: 'Close navigation',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
|
profile: 'Profile',
|
||||||
login: 'Log in',
|
login: 'Log in',
|
||||||
logout: 'Log out',
|
logout: 'Log out',
|
||||||
register: 'Register'
|
register: 'Register'
|
||||||
@@ -108,6 +109,19 @@ export const systemWordingMessages = {
|
|||||||
completeEmailVerification: 'Please complete email verification first.'
|
completeEmailVerification: 'Please complete email verification first.'
|
||||||
},
|
},
|
||||||
pages: {
|
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: {
|
pokemon: {
|
||||||
title: 'Pokemon',
|
title: 'Pokemon',
|
||||||
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
||||||
@@ -669,6 +683,7 @@ export const systemWordingMessages = {
|
|||||||
openMenu: '打开导航',
|
openMenu: '打开导航',
|
||||||
closeMenu: '关闭导航',
|
closeMenu: '关闭导航',
|
||||||
language: '语言',
|
language: '语言',
|
||||||
|
profile: '个人资料',
|
||||||
login: '登录',
|
login: '登录',
|
||||||
logout: '退出',
|
logout: '退出',
|
||||||
register: '注册'
|
register: '注册'
|
||||||
@@ -720,6 +735,19 @@ export const systemWordingMessages = {
|
|||||||
completeEmailVerification: '请先完成邮箱验证'
|
completeEmailVerification: '请先完成邮箱验证'
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
|
profile: {
|
||||||
|
title: '个人资料',
|
||||||
|
subtitle: '管理账号显示名和邮箱状态。',
|
||||||
|
loading: '正在加载个人资料',
|
||||||
|
accountSummary: '账号概览',
|
||||||
|
profileDetails: '资料详情',
|
||||||
|
displayNameHint: '显示名会用于编辑署名、讨论和 Life 动态。',
|
||||||
|
displayNameRequired: '请输入显示名。',
|
||||||
|
emailVerified: '邮箱已验证',
|
||||||
|
emailUnverified: '邮箱未验证',
|
||||||
|
saved: '个人资料已保存',
|
||||||
|
saveFailed: '个人资料保存失败'
|
||||||
|
},
|
||||||
pokemon: {
|
pokemon: {
|
||||||
title: 'Pokemon',
|
title: 'Pokemon',
|
||||||
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
||||||
|
|||||||
Reference in New Issue
Block a user