feat(web): 优化设置页面交互与语言同步

- 二级页面返回按钮导航到设置页而非路由栈上一页
- 通用设置开关等待后端响应后再更新 UI,失败时显示 toast
- 删除用户名/邮箱的硬编码默认值,使用 auth token 邮箱作为 fallback
- AppShell 侧边栏显示真实头像和用户名
- 页面加载时检查 URL 语言与用户偏好是否一致,不一致则重定向
This commit is contained in:
ZL-Q
2026-05-09 21:32:51 +08:00
parent 1d5efb46e7
commit a1b4418d55
5 changed files with 60 additions and 25 deletions
+30 -9
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, type ReactNode } from 'react'; import { useState, useEffect, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import Icon from './Icon'; import Icon from './Icon';
import { getAuth, refreshAccessToken, redirectToLogin, type AuthUser } from '../lib/auth'; import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth';
import { getUserProfile, type UserProfile } from '../lib/api';
interface NavItem { interface NavItem {
id: string; id: string;
@@ -49,6 +50,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [expandedNav, setExpandedNav] = useState<string | null>('divination'); const [expandedNav, setExpandedNav] = useState<string | null>('divination');
const [authUser, setAuthUser] = useState<AuthUser | null>(null); const [authUser, setAuthUser] = useState<AuthUser | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [checkingAuth, setCheckingAuth] = useState(true); const [checkingAuth, setCheckingAuth] = useState(true);
const activeNav = getActiveNav(navItems, locale, location.pathname); const activeNav = getActiveNav(navItems, locale, location.pathname);
@@ -63,6 +65,20 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
refreshAccessToken() refreshAccessToken()
.then((data) => { .then((data) => {
if (alive) setAuthUser(data.user); if (alive) setAuthUser(data.user);
return getUserProfile();
})
.then((profile) => {
if (!alive) return;
setUserProfile(profile);
// Check if URL locale matches user's language preference
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
if (locale !== userLocale) {
// Redirect to the correct locale
const currentPath = window.location.pathname;
const newPath = currentPath.replace(`/${locale}`, `/${userLocale}`);
window.location.replace(newPath);
}
}) })
.catch(() => { .catch(() => {
redirectToLogin(); redirectToLogin();
@@ -74,7 +90,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
return () => { return () => {
alive = false; alive = false;
}; };
}, []); }, [locale]);
useEffect(() => { useEffect(() => {
if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination'); if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination');
@@ -93,8 +109,9 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
); );
} }
const shellUserName = userName || authUser.email.split('@')[0]; const shellUserName = userName || userProfile?.display_name || authUser?.email?.split('@')[0] || '';
const shellUserEmail = userEmail || authUser.email; const shellUserEmail = userEmail || userProfile?.email || authUser?.email || '';
const shellAvatarUrl = userProfile?.avatar_url;
return ( return (
<div className="flex h-screen bg-slate-50 overflow-hidden"> <div className="flex h-screen bg-slate-50 overflow-hidden">
@@ -171,12 +188,16 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
</nav> </nav>
<a href={`/${locale}/profile`} onClick={(event) => { event.preventDefault(); navigate(`/${locale}/profile`); }} className={`flex items-center gap-3 p-3 rounded-[10px] hover:bg-slate-50 transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''}`} title={sidebarCollapsed ? shellUserName : undefined}> <a href={`/${locale}/profile`} onClick={(event) => { event.preventDefault(); navigate(`/${locale}/profile`); }} className={`flex items-center gap-3 p-3 rounded-[10px] hover:bg-slate-50 transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''}`} title={sidebarCollapsed ? shellUserName : undefined}>
<div className="w-9 h-9 rounded-full bg-violet-600 flex items-center justify-center text-white text-sm font-semibold"> {shellAvatarUrl ? (
{shellUserName[0].toUpperCase()} <img src={shellAvatarUrl} alt={shellUserName} className="w-9 h-9 rounded-full object-cover" />
</div> ) : (
<div className="w-9 h-9 rounded-full bg-violet-600 flex items-center justify-center text-white text-sm font-semibold">
{shellUserName ? shellUserName[0].toUpperCase() : '?'}
</div>
)}
<div className={`flex flex-col gap-1 min-w-0 ${sidebarCollapsed ? 'md:hidden' : ''}`}> <div className={`flex flex-col gap-1 min-w-0 ${sidebarCollapsed ? 'md:hidden' : ''}`}>
<p className="text-slate-900 text-sm font-medium truncate">{shellUserName}</p> <p className="text-slate-900 text-sm font-medium truncate">{shellUserName || '-'}</p>
<p className="text-slate-400 text-xs truncate">{shellUserEmail}</p> <p className="text-slate-400 text-xs truncate">{shellUserEmail || '-'}</p>
</div> </div>
</a> </a>
</aside> </aside>
+2 -2
View File
@@ -97,7 +97,7 @@ export default function FeedbackPage({ locale, feedback: f }: Props) {
} }
setToast({ type: 'success', message: f.success }); setToast({ type: 'success', message: f.success });
setTimeout(() => navigate(-1), 1500); setTimeout(() => navigate(`/${locale}/settings`), 1500);
} catch { } catch {
setToast({ type: 'error', message: f.error }); setToast({ type: 'error', message: f.error });
} finally { } finally {
@@ -110,7 +110,7 @@ export default function FeedbackPage({ locale, feedback: f }: Props) {
{/* Page Header */} {/* Page Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(`/${locale}/settings`)}
className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors" className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
> >
<span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span> <span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span>
+18 -5
View File
@@ -45,6 +45,7 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showLanguageModal, setShowLanguageModal] = useState(false); const [showLanguageModal, setShowLanguageModal] = useState(false);
const [toast, setToast] = useState<{ type: 'error'; message: string } | null>(null);
const selectedLanguage = settings.preferences.language; const selectedLanguage = settings.preferences.language;
const canSell = settings.privacy.can_sell; const canSell = settings.privacy.can_sell;
@@ -120,11 +121,16 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
}; };
} }
// Optimistically update UI setSaving(true);
setSettings(newSettings); setToast(null);
// Save to backend (silent) const success = await saveSettings(newSettings);
await saveSettings(newSettings); if (success) {
setSettings(newSettings);
} else {
setToast({ type: 'error', message: g.saveFailed });
}
setSaving(false);
}; };
const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage; const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage;
@@ -142,7 +148,7 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
{/* Page Header */} {/* Page Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(`/${locale}/settings`)}
className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors" className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
> >
<span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span> <span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span>
@@ -150,6 +156,13 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
<h1 className="text-slate-900 text-xl font-bold">{g.title}</h1> <h1 className="text-slate-900 text-xl font-bold">{g.title}</h1>
</div> </div>
{/* Toast */}
{toast && (
<div className="px-4 py-3 rounded-lg text-sm bg-red-50 text-red-500">
{toast.message}
</div>
)}
{/* Language Section */} {/* Language Section */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{g.languageLabel}</h3> <h3 className="text-slate-900 text-lg font-bold">{g.languageLabel}</h3>
+3 -3
View File
@@ -154,7 +154,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
); );
} }
const email = getAuth()?.user?.email || 'user@example.com'; const email = getAuth()?.user?.email || '';
return ( return (
<div className="flex flex-col lg:flex-row gap-6 min-h-full"> <div className="flex flex-col lg:flex-row gap-6 min-h-full">
@@ -165,7 +165,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
<img src={profile.avatar_url} alt={displayName} className="w-32 h-32 rounded-full object-cover border-2 border-violet-200" /> <img src={profile.avatar_url} alt={displayName} className="w-32 h-32 rounded-full object-cover border-2 border-violet-200" />
) : ( ) : (
<div className="w-32 h-32 rounded-full bg-violet-50 border-2 border-violet-200 flex items-center justify-center"> <div className="w-32 h-32 rounded-full bg-violet-50 border-2 border-violet-200 flex items-center justify-center">
<span className="text-violet-600 text-4xl font-bold">{(displayName || 'U')[0].toUpperCase()}</span> <span className="text-violet-600 text-4xl font-bold">{displayName ? displayName[0].toUpperCase() : '?'}</span>
</div> </div>
)} )}
<h3 className="text-slate-900 text-lg font-bold">{p.avatarTitle}</h3> <h3 className="text-slate-900 text-lg font-bold">{p.avatarTitle}</h3>
@@ -205,7 +205,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
<span className="material-symbols-rounded text-slate-400 text-lg">email</span> <span className="material-symbols-rounded text-slate-400 text-lg">email</span>
<div> <div>
<p className="text-slate-400 text-xs">{p.emailLabel}</p> <p className="text-slate-400 text-xs">{p.emailLabel}</p>
<p className="text-slate-600 text-sm">{email}</p> <p className="text-slate-600 text-sm">{email || '-'}</p>
</div> </div>
</div> </div>
+7 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { logout } from '../lib/auth'; import { logout, getAuth } from '../lib/auth';
import { getUserProfile, getPointsBalance, type UserProfile, type PointsBalance } from '../lib/api'; import { getUserProfile, getPointsBalance, type UserProfile, type PointsBalance } from '../lib/api';
interface Props { interface Props {
@@ -37,8 +37,9 @@ export default function SettingsPage({ locale, settings: s }: Props) {
} }
}; };
const displayName = profile?.display_name || profile?.email?.split('@')[0] || 'User'; const authEmail = getAuth()?.user?.email;
const email = profile?.email || 'user@example.com'; const displayName = loading ? '' : (profile?.display_name || profile?.email?.split('@')[0] || authEmail?.split('@')[0] || '');
const email = loading ? '' : (profile?.email || authEmail || '');
const bio = profile?.bio || ''; const bio = profile?.bio || '';
return ( return (
@@ -71,13 +72,13 @@ export default function SettingsPage({ locale, settings: s }: Props) {
<img src={profile.avatar_url} alt={displayName} className="w-14 h-14 rounded-[28px] object-cover" /> <img src={profile.avatar_url} alt={displayName} className="w-14 h-14 rounded-[28px] object-cover" />
) : ( ) : (
<div className="w-14 h-14 rounded-[28px] bg-violet-50 flex items-center justify-center"> <div className="w-14 h-14 rounded-[28px] bg-violet-50 flex items-center justify-center">
<span className="text-violet-600 text-xl font-bold">{displayName[0].toUpperCase()}</span> <span className="text-violet-600 text-xl font-bold">{displayName ? displayName[0].toUpperCase() : '?'}</span>
</div> </div>
)} )}
{/* Name & Email */} {/* Name & Email */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-slate-900 text-lg font-bold truncate">{displayName}</p> <p className="text-slate-900 text-lg font-bold truncate">{loading ? '...' : (displayName || '-')}</p>
<p className="text-slate-500 text-xs truncate">{email}</p> <p className="text-slate-500 text-xs truncate">{loading ? '...' : (email || '-')}</p>
</div> </div>
{/* Edit Profile Button */} {/* Edit Profile Button */}
<a <a