feat(web): 优化设置页面交互与语言同步
- 二级页面返回按钮导航到设置页而非路由栈上一页 - 通用设置开关等待后端响应后再更新 UI,失败时显示 toast - 删除用户名/邮箱的硬编码默认值,使用 auth token 邮箱作为 fallback - AppShell 侧边栏显示真实头像和用户名 - 页面加载时检查 URL 语言与用户偏好是否一致,不一致则重定向
This commit is contained in:
@@ -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}>
|
||||
<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()}
|
||||
</div>
|
||||
{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 ? 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user