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
+28 -7
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
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 {
id: string;
@@ -49,6 +50,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [expandedNav, setExpandedNav] = useState<string | null>('divination');
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [checkingAuth, setCheckingAuth] = useState(true);
const activeNav = getActiveNav(navItems, locale, location.pathname);
@@ -63,6 +65,20 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
refreshAccessToken()
.then((data) => {
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(() => {
redirectToLogin();
@@ -74,7 +90,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
return () => {
alive = false;
};
}, []);
}, [locale]);
useEffect(() => {
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 shellUserEmail = userEmail || authUser.email;
const shellUserName = userName || userProfile?.display_name || authUser?.email?.split('@')[0] || '';
const shellUserEmail = userEmail || userProfile?.email || authUser?.email || '';
const shellAvatarUrl = userProfile?.avatar_url;
return (
<div className="flex h-screen bg-slate-50 overflow-hidden">
@@ -171,12 +188,16 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
</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}>
{shellAvatarUrl ? (
<img src={shellAvatarUrl} alt={shellUserName} className="w-9 h-9 rounded-full object-cover" />
) : (
<div className="w-9 h-9 rounded-full bg-violet-600 flex items-center justify-center text-white text-sm font-semibold">
{shellUserName[0].toUpperCase()}
{shellUserName ? shellUserName[0].toUpperCase() : '?'}
</div>
)}
<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-400 text-xs truncate">{shellUserEmail}</p>
<p className="text-slate-900 text-sm font-medium truncate">{shellUserName || '-'}</p>
<p className="text-slate-400 text-xs truncate">{shellUserEmail || '-'}</p>
</div>
</a>
</aside>
+2 -2
View File
@@ -97,7 +97,7 @@ export default function FeedbackPage({ locale, feedback: f }: Props) {
}
setToast({ type: 'success', message: f.success });
setTimeout(() => navigate(-1), 1500);
setTimeout(() => navigate(`/${locale}/settings`), 1500);
} catch {
setToast({ type: 'error', message: f.error });
} finally {
@@ -110,7 +110,7 @@ export default function FeedbackPage({ locale, feedback: f }: Props) {
{/* Page Header */}
<div className="flex items-center gap-3">
<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"
>
<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 [saving, setSaving] = useState(false);
const [showLanguageModal, setShowLanguageModal] = useState(false);
const [toast, setToast] = useState<{ type: 'error'; message: string } | null>(null);
const selectedLanguage = settings.preferences.language;
const canSell = settings.privacy.can_sell;
@@ -120,11 +121,16 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
};
}
// Optimistically update UI
setSettings(newSettings);
setSaving(true);
setToast(null);
// Save to backend (silent)
await saveSettings(newSettings);
const success = 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;
@@ -142,7 +148,7 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
{/* Page Header */}
<div className="flex items-center gap-3">
<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"
>
<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>
</div>
{/* Toast */}
{toast && (
<div className="px-4 py-3 rounded-lg text-sm bg-red-50 text-red-500">
{toast.message}
</div>
)}
{/* Language Section */}
<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>
+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 (
<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" />
) : (
<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>
)}
<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>
<div>
<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>
+7 -6
View File
@@ -1,5 +1,5 @@
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';
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 email = profile?.email || 'user@example.com';
const authEmail = getAuth()?.user?.email;
const displayName = loading ? '' : (profile?.display_name || profile?.email?.split('@')[0] || authEmail?.split('@')[0] || '');
const email = loading ? '' : (profile?.email || authEmail || '');
const bio = profile?.bio || '';
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" />
) : (
<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>
)}
{/* Name & Email */}
<div className="flex-1 min-w-0">
<p className="text-slate-900 text-lg font-bold truncate">{displayName}</p>
<p className="text-slate-500 text-xs truncate">{email}</p>
<p className="text-slate-900 text-lg font-bold truncate">{loading ? '...' : (displayName || '-')}</p>
<p className="text-slate-500 text-xs truncate">{loading ? '...' : (email || '-')}</p>
</div>
{/* Edit Profile Button */}
<a