course_web01 / frontend /src /app /client-layout.tsx
trae-bot
Update project
426f2a4
"use client";
import Link from "next/link";
import { useAuthStore, useConfigStore } from "@/lib/store";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function ClientLayout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuthStore();
const { uiConfig, setUiConfig } = useConfigStore();
const router = useRouter();
const [isInitializing, setIsInitializing] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false);
const { setAuth, token } = useAuthStore();
useEffect(() => {
let isMounted = true;
const initApp = async () => {
try {
const configPromise = api.get('/api/config/ui').catch(e => {
console.error('Failed to fetch UI config:', e);
return null;
});
const authPromise = (async () => {
const storedToken = localStorage.getItem('token');
if (storedToken && !user) {
try {
const res = await api.get('/api/auth/profile');
if (res.success && isMounted) {
setAuth(res.data, storedToken);
}
} catch (err) {
console.error('Failed to restore auth:', err);
localStorage.removeItem('token');
}
}
})();
const [configRes] = await Promise.all([configPromise, authPromise]);
if (isMounted && configRes && configRes.success && configRes.data) {
setUiConfig(configRes.data);
}
} finally {
if (isMounted) {
setIsInitializing(false);
}
}
};
if (!uiConfig?.siteName || !user) {
initApp();
} else {
setIsInitializing(false);
}
return () => {
isMounted = false;
};
}, [setUiConfig, setAuth, uiConfig?.siteName, user]);
const handleLogout = () => {
logout();
router.push('/');
};
const handleUpgradeVip = async () => {
try {
const res = await api.post('/api/orders/create', { isVip: true });
if (res.success) {
router.push(`/payment/${res.data.orderId}`);
}
} catch (err) {
console.error(err);
alert('创建会员订单失败,请稍后重试');
}
};
const getAvatarColor = (name: string) => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash % 360);
return `hsl(${hue}, 70%, 50%)`;
};
if (isInitializing) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<p className="text-gray-500 font-medium">应用加载中...</p>
</div>
</div>
);
}
const siteName = uiConfig?.siteName || '极简AI';
const footerText = uiConfig?.footerText || ${new Date().getFullYear()} 极简AI. 保留所有权利。`;
const navLinks = uiConfig?.navLinks || [];
return (
<>
<header className="bg-white border-b sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-blue-600">
{uiConfig?.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={uiConfig.logo} alt={siteName} className="h-8 object-contain" />
) : (
siteName
)}
</Link>
<nav className="flex items-center gap-6">
<Link href="/" className="text-gray-600 hover:text-blue-600 font-medium">首页</Link>
{navLinks.map((link, idx) => (
<Link
key={idx}
href={`/?category=${link.value}`}
className="text-gray-600 hover:text-blue-600 font-medium"
>
{link.label}
</Link>
))}
{user ? (
<>
{user.role !== 'admin' && (
<>
<Link href="/user/stars" className="text-gray-600 hover:text-blue-600 font-medium">我的收藏</Link>
<Link href="/user/courses" className="text-gray-600 hover:text-blue-600 font-medium">我的学习</Link>
</>
)}
{user.role === 'admin' && (
<Link href="/admin" className="text-gray-600 hover:text-blue-600 font-medium">管理后台</Link>
)}
<div className="relative border-l pl-6 ml-2">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold cursor-pointer hover:opacity-90 transition-opacity select-none relative"
style={{ backgroundColor: getAvatarColor(user.nickname) }}
onClick={() => setDropdownOpen(!dropdownOpen)}
>
{user.nickname.charAt(0).toUpperCase()}
{user.isVip && (
<div className="absolute -bottom-1 -right-1 bg-yellow-400 text-xs text-white px-1.5 rounded-full border-2 border-white shadow-sm font-bold scale-75">
VIP
</div>
)}
</div>
{dropdownOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setDropdownOpen(false)}
></div>
<div className="absolute right-0 mt-3 w-48 bg-white rounded-xl shadow-lg border border-gray-100 py-2 z-50 overflow-hidden">
<div className="px-4 py-2 border-b border-gray-50 mb-1">
<div className="text-sm font-medium text-gray-900 truncate">{user.nickname}</div>
<div className="text-xs text-gray-500 truncate">{user.email}</div>
</div>
{!user.isVip && (
<button
onClick={() => {
setDropdownOpen(false);
handleUpgradeVip();
}}
className="w-full text-left px-4 py-2.5 text-sm text-yellow-600 hover:bg-yellow-50 font-medium flex items-center transition-colors"
>
<span className="mr-2">👑</span>
注册会员 (¥{uiConfig?.memberFee ?? 99})
</button>
)}
<button
onClick={() => {
setDropdownOpen(false);
handleLogout();
}}
className="w-full text-left px-4 py-2.5 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
>
退出登录
</button>
</div>
</>
)}
</div>
</>
) : (
<Link href="/login" className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm">
登录 / 注册
</Link>
)}
</nav>
</div>
</header>
<main className="flex-1 max-w-7xl mx-auto w-full px-4 py-8">
{children}
</main>
<footer className="bg-white border-t py-8 mt-auto">
<div className="max-w-7xl mx-auto px-4 text-center text-gray-500 text-sm">
{footerText}
</div>
</footer>
</>
);
}