提供基本前后端骨架

This commit is contained in:
hisatri
2026-01-06 23:49:23 +08:00
parent 84d4ccc226
commit 06f8176e23
89 changed files with 19293 additions and 2 deletions

View File

@@ -0,0 +1,37 @@
'use client';
import { useEffect, ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/store';
import { DashboardLayout } from '@/components/dashboard';
import { PageLoader } from '@/components/ui';
interface LayoutProps {
children: ReactNode;
}
export default function Layout({ children }: LayoutProps) {
const router = useRouter();
const { isAuthenticated, isLoading, fetchUser } = useAuthStore();
useEffect(() => {
fetchUser();
}, [fetchUser]);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace('/login');
}
}, [isLoading, isAuthenticated, router]);
if (isLoading) {
return <PageLoader />;
}
if (!isAuthenticated) {
return <PageLoader />;
}
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,152 @@
'use client';
import { motion } from 'framer-motion';
import {
Shield,
Clock,
User,
Mail,
Calendar,
Activity,
} from 'lucide-react';
import { Card, CardContent, Avatar } from '@/components/ui';
import { useAuthStore } from '@/lib/store';
import { formatDate, getDisplayName, getAvatarUrl } from '@/lib/utils';
export default function DashboardPage() {
const { user } = useAuthStore();
if (!user) return null;
const stats = [
{
label: '账户状态',
value: user.is_active ? '正常' : '已禁用',
icon: Shield,
color: user.is_active ? 'text-accent-green' : 'text-accent-red',
},
{
label: '注册时间',
value: formatDate(user.created_at).split(' ')[0],
icon: Calendar,
color: 'text-primary',
},
{
label: '最近登录',
value: user.last_login_at ? formatDate(user.last_login_at).split(' ')[0] : '首次登录',
icon: Clock,
color: 'text-accent-cyan',
},
];
return (
<div className="p-6 lg:p-10">
{/* 页面标题 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">
{getDisplayName(user)}
</h1>
<p className="text-foreground-muted mt-2">
</p>
</motion.div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{stats.map((stat, index) => (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card hover className="h-full">
<CardContent className="flex items-center gap-4">
<div className={`p-3 rounded-xl bg-background-tertiary ${stat.color}`}>
<stat.icon className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-foreground-muted">{stat.label}</p>
<p className="text-lg font-semibold text-foreground">{stat.value}</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
{/* 用户信息卡片 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card>
<CardContent>
<div className="flex flex-col lg:flex-row lg:items-start gap-6">
{/* 头像 */}
<div className="flex-shrink-0">
<Avatar
src={getAvatarUrl(user)}
alt={user.username}
size="xl"
className="ring-4 ring-primary/20"
/>
</div>
{/* 用户详情 */}
<div className="flex-1 space-y-4">
<div>
<h2 className="text-xl font-bold text-foreground">
{getDisplayName(user)}
</h2>
<p className="text-foreground-muted">@{user.username}</p>
</div>
{user.bio && (
<p className="text-foreground-muted leading-relaxed">
{user.bio}
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border">
<div className="flex items-center gap-3 text-foreground-muted">
<User className="w-5 h-5" />
<span>ID: {user.id.slice(0, 8)}...</span>
</div>
{user.email && (
<div className="flex items-center gap-3 text-foreground-muted">
<Mail className="w-5 h-5" />
<span>{user.email}</span>
</div>
)}
<div className="flex items-center gap-3 text-foreground-muted">
<Calendar className="w-5 h-5" />
<span> {formatDate(user.created_at)}</span>
</div>
<div className="flex items-center gap-3 text-foreground-muted">
<Activity className="w-5 h-5" />
<span>
{user.last_login_at
? `最近活跃于 ${formatDate(user.last_login_at)}`
: '首次登录'}
</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { User, Mail, FileText, Link as LinkIcon, Save, CheckCircle } from 'lucide-react';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
Alert,
Avatar,
} from '@/components/ui';
import { useAuthStore } from '@/lib/store';
import { updateProfileSchema, UpdateProfileFormData } from '@/lib/validations';
import { getAvatarUrl, getDisplayName } from '@/lib/utils';
export default function ProfilePage() {
const { user, updateUser, error, clearError, isLoading } = useAuthStore();
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
} = useForm<UpdateProfileFormData>({
resolver: zodResolver(updateProfileSchema),
defaultValues: {
nickname: user?.nickname || '',
email: user?.email || '',
bio: user?.bio || '',
avatar_url: user?.avatar_url || '',
},
});
const onSubmit = async (data: UpdateProfileFormData) => {
setSuccess(false);
try {
await updateUser({
nickname: data.nickname || undefined,
email: data.email || undefined,
bio: data.bio || undefined,
avatar_url: data.avatar_url || undefined,
});
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch {
// 错误已在 store 中处理
}
};
if (!user) return null;
return (
<div className="p-6 lg:p-10 max-w-4xl">
{/* 页面标题 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">
</h1>
<p className="text-foreground-muted mt-2">
</p>
</motion.div>
{/* 当前头像预览 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="mb-6"
>
<Card>
<CardContent className="flex items-center gap-6">
<Avatar
src={getAvatarUrl(user)}
alt={user.username}
size="xl"
/>
<div>
<h3 className="text-lg font-semibold text-foreground">
{getDisplayName(user)}
</h3>
<p className="text-foreground-muted">@{user.username}</p>
<p className="text-sm text-foreground-subtle mt-2">
</p>
</div>
</CardContent>
</Card>
</motion.div>
{/* 编辑表单 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
{/* 提示信息 */}
{error && (
<Alert variant="error" className="mb-6" onClose={clearError}>
{error}
</Alert>
)}
{success && (
<Alert variant="success" className="mb-6">
<CheckCircle className="w-4 h-4 inline mr-2" />
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Input
{...register('nickname')}
label="昵称"
placeholder="请输入昵称"
leftIcon={<User className="w-5 h-5" />}
error={errors.nickname?.message}
/>
<Input
{...register('email')}
type="email"
label="邮箱"
placeholder="请输入邮箱"
leftIcon={<Mail className="w-5 h-5" />}
error={errors.email?.message}
/>
<Input
{...register('avatar_url')}
label="头像 URL"
placeholder="请输入头像图片地址"
leftIcon={<LinkIcon className="w-5 h-5" />}
error={errors.avatar_url?.message}
hint="支持 http/https 链接"
/>
<div>
<label className="block text-sm font-medium text-foreground-muted mb-2">
</label>
<div className="relative">
<FileText className="absolute left-4 top-4 w-5 h-5 text-foreground-subtle" />
<textarea
{...register('bio')}
className="w-full px-4 py-3 pl-12 rounded-xl bg-background-tertiary text-foreground border border-border placeholder:text-foreground-subtle transition-all duration-200 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"
rows={4}
placeholder="介绍一下你自己..."
/>
</div>
{errors.bio && (
<p className="mt-1.5 text-sm text-accent-red">
{errors.bio.message}
</p>
)}
</div>
<div className="flex justify-end pt-4 border-t border-border">
<Button
type="submit"
isLoading={isLoading}
disabled={!isDirty}
leftIcon={<Save className="w-5 h-5" />}
>
</Button>
</div>
</form>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { Lock, Shield, CheckCircle, AlertTriangle } from 'lucide-react';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
Alert,
} from '@/components/ui';
import { useAuthStore } from '@/lib/store';
import { changePasswordSchema, ChangePasswordFormData } from '@/lib/validations';
export default function SettingsPage() {
const { changePassword, error, clearError, isLoading } = useAuthStore();
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
});
const onSubmit = async (data: ChangePasswordFormData) => {
setSuccess(false);
try {
await changePassword(data.currentPassword, data.newPassword);
setSuccess(true);
reset();
setTimeout(() => setSuccess(false), 5000);
} catch {
// 错误已在 store 中处理
}
};
return (
<div className="p-6 lg:p-10 max-w-4xl">
{/* 页面标题 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">
</h1>
<p className="text-foreground-muted mt-2">
</p>
</motion.div>
{/* 修改密码 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="mb-6"
>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Shield className="w-5 h-5" />
</div>
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* 提示信息 */}
{error && (
<Alert variant="error" className="mb-6" onClose={clearError}>
{error}
</Alert>
)}
{success && (
<Alert variant="success" className="mb-6">
<CheckCircle className="w-4 h-4 inline mr-2" />
使
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Input
{...register('currentPassword')}
type="password"
label="当前密码"
placeholder="请输入当前密码"
leftIcon={<Lock className="w-5 h-5" />}
error={errors.currentPassword?.message}
autoComplete="current-password"
/>
<Input
{...register('newPassword')}
type="password"
label="新密码"
placeholder="请输入新密码"
leftIcon={<Lock className="w-5 h-5" />}
error={errors.newPassword?.message}
hint="8-128 位,需包含大小写字母和数字"
autoComplete="new-password"
/>
<Input
{...register('confirmPassword')}
type="password"
label="确认新密码"
placeholder="请再次输入新密码"
leftIcon={<Lock className="w-5 h-5" />}
error={errors.confirmPassword?.message}
autoComplete="new-password"
/>
<div className="flex justify-end pt-4 border-t border-border">
<Button
type="submit"
isLoading={isLoading}
leftIcon={<Lock className="w-5 h-5" />}
>
</Button>
</div>
</form>
</CardContent>
</Card>
</motion.div>
{/* 安全提示 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card>
<CardContent>
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-accent-yellow/10 text-accent-yellow">
<AlertTriangle className="w-5 h-5" />
</div>
<div>
<h3 className="font-medium text-foreground mb-2"></h3>
<ul className="text-sm text-foreground-muted space-y-2">
<li className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-foreground-subtle" />
</li>
<li className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-foreground-subtle" />
使
</li>
<li className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-foreground-subtle" />
</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,256 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ========================================================================== */
/* CSS Variables */
/* ========================================================================== */
:root {
--color-background: 15 15 20;
--color-foreground: 232 232 237;
}
/* ========================================================================== */
/* Base Styles */
/* ========================================================================== */
@layer base {
* {
@apply border-border;
}
html {
@apply scroll-smooth antialiased;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-background text-foreground font-sans;
min-height: 100vh;
overflow-x: hidden;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-background-secondary;
}
::-webkit-scrollbar-thumb {
@apply bg-border-hover rounded-full;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-foreground-subtle;
}
/* 选中文本样式 */
::selection {
@apply bg-primary/30 text-foreground;
}
/* 输入框自动填充样式 */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: theme('colors.foreground.DEFAULT');
-webkit-box-shadow: 0 0 0px 1000px theme('colors.background.tertiary') inset;
transition: background-color 5000s ease-in-out 0s;
}
}
/* ========================================================================== */
/* Component Styles */
/* ========================================================================== */
@layer components {
/* 主按钮 */
.btn-primary {
@apply inline-flex items-center justify-center gap-2
px-6 py-3 rounded-xl
bg-primary text-background font-medium
transition-all duration-200 ease-out
hover:bg-primary-hover hover:shadow-glow
active:scale-[0.98]
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none;
}
/* 次要按钮 */
.btn-secondary {
@apply inline-flex items-center justify-center gap-2
px-6 py-3 rounded-xl
bg-background-tertiary text-foreground font-medium
border border-border
transition-all duration-200 ease-out
hover:bg-background-elevated hover:border-border-hover
active:scale-[0.98]
disabled:opacity-50 disabled:cursor-not-allowed;
}
/* 幽灵按钮 */
.btn-ghost {
@apply inline-flex items-center justify-center gap-2
px-4 py-2 rounded-lg
text-foreground-muted font-medium
transition-all duration-200 ease-out
hover:text-foreground hover:bg-background-tertiary
active:scale-[0.98];
}
/* 输入框 */
.input {
@apply w-full px-4 py-3 rounded-xl
bg-background-tertiary text-foreground
border border-border
placeholder:text-foreground-subtle
transition-all duration-200 ease-out
focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20
disabled:opacity-50 disabled:cursor-not-allowed;
}
/* 标签 */
.label {
@apply block text-sm font-medium text-foreground-muted mb-2;
}
/* 卡片 */
.card {
@apply bg-background-secondary rounded-2xl border border-border
transition-all duration-300 ease-out;
}
.card-hover {
@apply hover:border-border-hover hover:shadow-lg hover:shadow-primary/5;
}
/* 链接 */
.link {
@apply text-primary hover:text-primary-hover
transition-colors duration-200
underline-offset-4 hover:underline;
}
/* 分隔线 */
.divider {
@apply h-px bg-gradient-to-r from-transparent via-border to-transparent;
}
/* 表单错误 */
.error-text {
@apply text-sm text-accent-red mt-1.5 flex items-center gap-1;
}
/* 成功文本 */
.success-text {
@apply text-sm text-accent-green mt-1.5 flex items-center gap-1;
}
}
/* ========================================================================== */
/* Utility Styles */
/* ========================================================================== */
@layer utilities {
/* 渐变文本 */
.text-gradient {
@apply bg-gradient-to-r from-primary via-accent to-accent-cyan
bg-clip-text text-transparent;
}
/* 玻璃效果 */
.glass {
@apply bg-background-secondary/80 backdrop-blur-xl
border border-white/5;
}
/* 网格背景 */
.bg-grid {
background-image:
linear-gradient(rgba(122, 162, 247, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(122, 162, 247, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
}
/* 点阵背景 */
.bg-dots {
background-image: radial-gradient(rgba(122, 162, 247, 0.15) 1px, transparent 1px);
background-size: 20px 20px;
}
/* 闪烁效果 */
.shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(122, 162, 247, 0.1) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 2s linear infinite;
}
/* 隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
/* ========================================================================== */
/* Animations */
/* ========================================================================== */
/* 页面过渡动画 */
.page-enter {
opacity: 0;
transform: translateY(20px);
}
.page-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease-out, transform 400ms ease-out;
}
/* 浮动动画 */
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
/* 脉冲光晕 */
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(122, 162, 247, 0.3);
}
50% {
box-shadow: 0 0 40px rgba(122, 162, 247, 0.5);
}
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}

View File

@@ -0,0 +1,32 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'SatoNano - 云服务综合管理平台',
description: '现代化用户认证系统',
icons: {
icon: '/favicon.svg',
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className="min-h-screen bg-background antialiased">
{/* 背景装饰 */}
<div className="fixed inset-0 bg-gradient-mesh pointer-events-none" />
<div className="fixed inset-0 bg-grid pointer-events-none opacity-50" />
{/* 主内容 */}
<div className="relative z-10">
{children}
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/store';
import { AuthLayout, LoginForm } from '@/components/auth';
import { PageLoader } from '@/components/ui';
export default function LoginPage() {
const router = useRouter();
const { isAuthenticated, isLoading, fetchUser } = useAuthStore();
useEffect(() => {
fetchUser();
}, [fetchUser]);
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.replace('/dashboard');
}
}, [isLoading, isAuthenticated, router]);
if (isLoading) {
return <PageLoader />;
}
if (isAuthenticated) {
return <PageLoader />;
}
return (
<AuthLayout
title="欢迎回来"
subtitle="登录您的账户以继续"
>
<LoginForm />
</AuthLayout>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Home, ArrowLeft } from 'lucide-react';
import { Logo, Button } from '@/components/ui';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<motion.div
className="max-w-md w-full text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Logo size="lg" className="justify-center mb-10" />
{/* 404 动画 */}
<motion.div
className="text-9xl font-bold text-gradient mb-6"
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', duration: 0.8 }}
>
404
</motion.div>
<h1 className="text-2xl font-bold text-foreground mb-4">
</h1>
<p className="text-foreground-muted mb-8">
访
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
variant="secondary"
onClick={() => window.history.back()}
leftIcon={<ArrowLeft className="w-5 h-5" />}
>
</Button>
<Link href="/">
<Button leftIcon={<Home className="w-5 h-5" />}>
</Button>
</Link>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { motion } from 'framer-motion';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { Logo, Button } from '@/components/ui';
import { oauth2Api, tokenStorage } from '@/lib/api';
import { useAuthStore } from '@/lib/store';
function OAuth2CallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { fetchUser } = useAuthStore();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState('正在处理授权...');
const [isNewUser, setIsNewUser] = useState(false);
useEffect(() => {
const handleCallback = async () => {
const code = searchParams.get('code');
const state = searchParams.get('state');
const savedState = sessionStorage.getItem('oauth2_state');
// 验证 state
if (!code || !state) {
setStatus('error');
setMessage('缺少授权参数');
return;
}
if (state !== savedState) {
setStatus('error');
setMessage('安全验证失败,请重新登录');
return;
}
try {
const response = await oauth2Api.callback(code, state);
// 保存令牌
tokenStorage.setTokens(
response.data.access_token,
response.data.refresh_token
);
// 清除保存的 state
sessionStorage.removeItem('oauth2_state');
// 获取用户信息
await fetchUser();
setIsNewUser(response.data.is_new_user || false);
setStatus('success');
setMessage(response.data.is_new_user ? '账户创建成功!' : '登录成功!');
// 延迟跳转
setTimeout(() => {
router.replace('/dashboard');
}, 1500);
} catch (err) {
setStatus('error');
setMessage(err instanceof Error ? err.message : '授权失败,请重试');
}
};
handleCallback();
}, [searchParams, fetchUser, router]);
return (
<div className="min-h-screen flex items-center justify-center p-6">
<motion.div
className="max-w-md w-full text-center"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<Logo size="lg" className="justify-center mb-10" />
<div className="card p-8">
{/* 状态图标 */}
<div className="mb-6">
{status === 'loading' && (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<Loader2 className="w-16 h-16 mx-auto text-primary" />
</motion.div>
)}
{status === 'success' && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', duration: 0.5 }}
>
<CheckCircle className="w-16 h-16 mx-auto text-accent-green" />
</motion.div>
)}
{status === 'error' && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', duration: 0.5 }}
>
<XCircle className="w-16 h-16 mx-auto text-accent-red" />
</motion.div>
)}
</div>
{/* 消息 */}
<h2 className="text-xl font-semibold text-foreground mb-2">
{status === 'loading' && 'OAuth2 授权'}
{status === 'success' && (isNewUser ? '欢迎加入!' : '欢迎回来!')}
{status === 'error' && '授权失败'}
</h2>
<p className="text-foreground-muted mb-6">{message}</p>
{/* 操作按钮 */}
{status === 'error' && (
<div className="space-y-3">
<Button
onClick={() => router.push('/login')}
className="w-full"
>
</Button>
</div>
)}
{status === 'success' && (
<p className="text-sm text-foreground-subtle">
...
</p>
)}
</div>
</motion.div>
</div>
);
}
export default function OAuth2CallbackPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
}>
<OAuth2CallbackContent />
</Suspense>
);
}

28
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,28 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/store';
import { PageLoader } from '@/components/ui';
export default function HomePage() {
const router = useRouter();
const { isAuthenticated, isLoading, fetchUser } = useAuthStore();
useEffect(() => {
fetchUser();
}, [fetchUser]);
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.replace('/dashboard');
} else {
router.replace('/login');
}
}
}, [isLoading, isAuthenticated, router]);
return <PageLoader />;
}

View File

@@ -0,0 +1,40 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/store';
import { AuthLayout, RegisterForm } from '@/components/auth';
import { PageLoader } from '@/components/ui';
export default function RegisterPage() {
const router = useRouter();
const { isAuthenticated, isLoading, fetchUser } = useAuthStore();
useEffect(() => {
fetchUser();
}, [fetchUser]);
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.replace('/dashboard');
}
}, [isLoading, isAuthenticated, router]);
if (isLoading) {
return <PageLoader />;
}
if (isAuthenticated) {
return <PageLoader />;
}
return (
<AuthLayout
title="创建账户"
subtitle="填写以下信息注册新账户"
>
<RegisterForm />
</AuthLayout>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import { ReactNode } from 'react';
import { motion } from 'framer-motion';
import { Logo } from '@/components/ui';
interface AuthLayoutProps {
children: ReactNode;
title: string;
subtitle?: string;
}
export function AuthLayout({ children, title, subtitle }: AuthLayoutProps) {
return (
<div className="min-h-screen flex">
{/* 左侧装饰区域 */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
{/* 背景图案 */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-background to-accent/10" />
<div className="absolute inset-0 bg-dots opacity-30" />
{/* 装饰元素 */}
<div className="absolute top-20 left-20">
<motion.div
className="w-64 h-64 rounded-full bg-primary/10 blur-3xl"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</div>
<div className="absolute bottom-32 right-20">
<motion.div
className="w-96 h-96 rounded-full bg-accent/10 blur-3xl"
animate={{
scale: [1.2, 1, 1.2],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 10,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</div>
{/* 内容 */}
<div className="relative z-10 flex flex-col justify-center px-16 xl:px-24">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Logo size="lg" className="mb-12" />
<h1 className="text-4xl xl:text-5xl font-bold text-foreground leading-tight mb-6">
<span className="block text-gradient"></span>
</h1>
<p className="text-lg text-foreground-muted max-w-md leading-relaxed">
</p>
</motion.div>
{/* 特性列表 */}
<motion.div
className="mt-12 space-y-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.6 }}
>
{[
'安全的 JWT 认证机制',
'支持 OAuth2 第三方登录',
'完善的密码策略',
].map((feature, index) => (
<motion.div
key={feature}
className="flex items-center gap-3 text-foreground-muted"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 + index * 0.1 }}
>
<div className="w-2 h-2 rounded-full bg-primary" />
<span>{feature}</span>
</motion.div>
))}
</motion.div>
</div>
</div>
{/* 右侧表单区域 */}
<div className="flex-1 flex items-center justify-center p-6 sm:p-12">
<motion.div
className="w-full max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* 移动端 Logo */}
<div className="lg:hidden mb-10">
<Logo size="md" />
</div>
{/* 标题 */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-foreground">{title}</h2>
{subtitle && (
<p className="mt-2 text-foreground-muted">{subtitle}</p>
)}
</div>
{/* 表单内容 */}
{children}
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { User, Lock, ExternalLink } from 'lucide-react';
import { Button, Input, Alert } from '@/components/ui';
import { useAuthStore } from '@/lib/store';
import { loginSchema, LoginFormData } from '@/lib/validations';
import { oauth2Api } from '@/lib/api';
export function LoginForm() {
const router = useRouter();
const { login, error, clearError, isLoading } = useAuthStore();
const [oauth2Loading, setOauth2Loading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
await login(data.username, data.password);
router.push('/dashboard');
} catch {
// 错误已在 store 中处理
}
};
const handleOAuth2Login = async () => {
setOauth2Loading(true);
try {
const response = await oauth2Api.getAuthorizeUrl();
// 保存 state 到 sessionStorage 用于回调验证
sessionStorage.setItem('oauth2_state', response.data.state);
// 重定向到授权页面
window.location.href = response.data.authorize_url;
} catch (err) {
console.error('OAuth2 error:', err);
setOauth2Loading(false);
}
};
return (
<div className="space-y-6">
{/* 错误提示 */}
{error && (
<Alert variant="error" onClose={clearError}>
{error}
</Alert>
)}
{/* 登录表单 */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<Input
{...register('username')}
label="用户名或邮箱"
placeholder="请输入用户名或邮箱"
leftIcon={<User className="w-5 h-5" />}
error={errors.username?.message}
autoComplete="username"
/>
<Input
{...register('password')}
type="password"
label="密码"
placeholder="请输入密码"
leftIcon={<Lock className="w-5 h-5" />}
error={errors.password?.message}
autoComplete="current-password"
/>
<Button
type="submit"
className="w-full"
isLoading={isLoading}
>
</Button>
</form>
{/* 分隔线 */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-background text-foreground-muted">
使
</span>
</div>
</div>
{/* OAuth2 登录 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Button
type="button"
variant="secondary"
className="w-full"
onClick={handleOAuth2Login}
isLoading={oauth2Loading}
leftIcon={<ExternalLink className="w-5 h-5" />}
>
使 Linux.do
</Button>
</motion.div>
{/* 注册链接 */}
<p className="text-center text-foreground-muted">
{' '}
<Link href="/register" className="link font-medium">
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { User, Lock, Mail, UserCircle } from 'lucide-react';
import { Button, Input, Alert } from '@/components/ui';
import { useAuthStore } from '@/lib/store';
import { registerSchema, RegisterFormData } from '@/lib/validations';
export function RegisterForm() {
const router = useRouter();
const { register: registerUser, error, clearError, isLoading } = useAuthStore();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterFormData) => {
try {
await registerUser(
data.username,
data.password,
data.email || undefined,
data.nickname || undefined
);
router.push('/dashboard');
} catch {
// 错误已在 store 中处理
}
};
return (
<div className="space-y-6">
{/* 错误提示 */}
{error && (
<Alert variant="error" onClose={clearError}>
{error}
</Alert>
)}
{/* 注册表单 */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<Input
{...register('username')}
label="用户名"
placeholder="请输入用户名"
leftIcon={<User className="w-5 h-5" />}
error={errors.username?.message}
hint="3-32 位,以字母开头,只允许字母、数字、下划线"
autoComplete="username"
/>
<Input
{...register('email')}
type="email"
label="邮箱(可选)"
placeholder="请输入邮箱"
leftIcon={<Mail className="w-5 h-5" />}
error={errors.email?.message}
autoComplete="email"
/>
<Input
{...register('nickname')}
label="昵称(可选)"
placeholder="请输入昵称"
leftIcon={<UserCircle className="w-5 h-5" />}
error={errors.nickname?.message}
/>
<Input
{...register('password')}
type="password"
label="密码"
placeholder="请输入密码"
leftIcon={<Lock className="w-5 h-5" />}
error={errors.password?.message}
hint="8-128 位,需包含大小写字母和数字"
autoComplete="new-password"
/>
<Input
{...register('confirmPassword')}
type="password"
label="确认密码"
placeholder="请再次输入密码"
leftIcon={<Lock className="w-5 h-5" />}
error={errors.confirmPassword?.message}
autoComplete="new-password"
/>
<Button
type="submit"
className="w-full"
isLoading={isLoading}
>
</Button>
</form>
{/* 登录链接 */}
<p className="text-center text-foreground-muted">
{' '}
<Link href="/login" className="link font-medium">
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { AuthLayout } from './AuthLayout';
export { LoginForm } from './LoginForm';
export { RegisterForm } from './RegisterForm';

View File

@@ -0,0 +1,233 @@
'use client';
import { ReactNode, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import {
LayoutDashboard,
User,
Settings,
LogOut,
Menu,
X,
ChevronRight,
} from 'lucide-react';
import { Logo, Avatar, Button } from '@/components/ui';
import { useAuthStore } from '@/lib/store';
import { getDisplayName, getAvatarUrl } from '@/lib/utils';
interface DashboardLayoutProps {
children: ReactNode;
}
const navigation = [
{ name: '控制台', href: '/dashboard', icon: LayoutDashboard },
{ name: '个人资料', href: '/dashboard/profile', icon: User },
{ name: '账户设置', href: '/dashboard/settings', icon: Settings },
];
export function DashboardLayout({ children }: DashboardLayoutProps) {
const pathname = usePathname();
const router = useRouter();
const { user, logout } = useAuthStore();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const handleLogout = async () => {
await logout();
router.push('/login');
};
if (!user) return null;
return (
<div className="min-h-screen flex">
{/* 桌面端侧边栏 */}
<aside className="hidden lg:flex lg:flex-col lg:w-72 lg:fixed lg:inset-y-0 border-r border-border bg-background-secondary/50">
{/* Logo */}
<div className="flex items-center h-16 px-6 border-b border-border">
<Link href="/dashboard">
<Logo size="sm" />
</Link>
</div>
{/* 导航菜单 */}
<nav className="flex-1 px-4 py-6 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`
flex items-center gap-3 px-4 py-3 rounded-xl
transition-all duration-200
${isActive
? 'bg-primary/10 text-primary'
: 'text-foreground-muted hover:text-foreground hover:bg-background-tertiary'
}
`}
>
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.name}</span>
{isActive && (
<motion.div
layoutId="activeNav"
className="ml-auto w-1.5 h-1.5 rounded-full bg-primary"
/>
)}
</Link>
);
})}
</nav>
{/* 用户信息 */}
<div className="p-4 border-t border-border">
<div className="flex items-center gap-3 p-3 rounded-xl bg-background-tertiary">
<Avatar
src={getAvatarUrl(user)}
alt={user.username}
size="md"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{getDisplayName(user)}
</p>
<p className="text-xs text-foreground-muted truncate">
@{user.username}
</p>
</div>
</div>
<Button
variant="ghost"
className="w-full mt-3 justify-start text-foreground-muted hover:text-accent-red"
leftIcon={<LogOut className="w-5 h-5" />}
onClick={handleLogout}
>
退
</Button>
</div>
</aside>
{/* 移动端头部 */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 h-16 border-b border-border bg-background/80 backdrop-blur-xl">
<div className="flex items-center justify-between h-full px-4">
<Link href="/dashboard">
<Logo size="sm" />
</Link>
<button
onClick={() => setMobileMenuOpen(true)}
className="p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-background-tertiary"
>
<Menu className="w-6 h-6" />
</button>
</div>
</div>
{/* 移动端菜单 */}
<AnimatePresence>
{mobileMenuOpen && (
<>
{/* 遮罩 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setMobileMenuOpen(false)}
/>
{/* 菜单面板 */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="lg:hidden fixed right-0 top-0 bottom-0 z-50 w-80 bg-background-secondary border-l border-border"
>
<div className="flex items-center justify-between h-16 px-4 border-b border-border">
<span className="font-medium"></span>
<button
onClick={() => setMobileMenuOpen(false)}
className="p-2 rounded-lg text-foreground-muted hover:text-foreground hover:bg-background-tertiary"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 用户信息 */}
<div className="p-4 border-b border-border">
<div className="flex items-center gap-3">
<Avatar
src={getAvatarUrl(user)}
alt={user.username}
size="lg"
/>
<div>
<p className="font-medium text-foreground">
{getDisplayName(user)}
</p>
<p className="text-sm text-foreground-muted">
@{user.username}
</p>
</div>
</div>
</div>
{/* 导航 */}
<nav className="p-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className={`
flex items-center justify-between px-4 py-3 rounded-xl
transition-all duration-200
${isActive
? 'bg-primary/10 text-primary'
: 'text-foreground-muted hover:text-foreground hover:bg-background-tertiary'
}
`}
>
<div className="flex items-center gap-3">
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.name}</span>
</div>
<ChevronRight className="w-4 h-4" />
</Link>
);
})}
</nav>
{/* 退出 */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-border">
<Button
variant="ghost"
className="w-full justify-start text-foreground-muted hover:text-accent-red"
leftIcon={<LogOut className="w-5 h-5" />}
onClick={handleLogout}
>
退
</Button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* 主内容区 */}
<main className="flex-1 lg:pl-72">
<div className="pt-16 lg:pt-0 min-h-screen">
{children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { DashboardLayout } from './DashboardLayout';

View File

@@ -0,0 +1,96 @@
'use client';
import { forwardRef, ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { AlertCircle, CheckCircle, Info, XCircle, X } from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================================
// 类型定义
// ============================================================================
type AlertVariant = 'info' | 'success' | 'warning' | 'error';
interface AlertProps {
variant?: AlertVariant;
title?: string;
onClose?: () => void;
show?: boolean;
className?: string;
children?: ReactNode;
}
// ============================================================================
// 配置
// ============================================================================
const variants: Record<AlertVariant, { icon: React.ReactNode; className: string }> = {
info: {
icon: <Info className="w-5 h-5" />,
className: 'bg-primary/10 border-primary/20 text-primary',
},
success: {
icon: <CheckCircle className="w-5 h-5" />,
className: 'bg-accent-green/10 border-accent-green/20 text-accent-green',
},
warning: {
icon: <AlertCircle className="w-5 h-5" />,
className: 'bg-accent-yellow/10 border-accent-yellow/20 text-accent-yellow',
},
error: {
icon: <XCircle className="w-5 h-5" />,
className: 'bg-accent-red/10 border-accent-red/20 text-accent-red',
},
};
// ============================================================================
// 组件
// ============================================================================
export const Alert = forwardRef<HTMLDivElement, AlertProps>(
({ className, variant = 'info', title, children, onClose, show = true }, ref) => {
const config = variants[variant];
return (
<AnimatePresence>
{show && (
<motion.div
ref={ref}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className={cn(
'relative flex gap-3 p-4 rounded-xl border',
config.className,
className
)}
role="alert"
>
<div className="flex-shrink-0 mt-0.5">{config.icon}</div>
<div className="flex-1 min-w-0">
{title && (
<h4 className="font-medium mb-1">{title}</h4>
)}
<div className="text-sm opacity-90">{children}</div>
</div>
{onClose && (
<button
onClick={onClose}
className="flex-shrink-0 p-1 rounded-lg hover:bg-white/10 transition-colors"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
)}
</motion.div>
)}
</AnimatePresence>
);
}
);
Alert.displayName = 'Alert';

View File

@@ -0,0 +1,59 @@
'use client';
import Image from 'next/image';
import { cn } from '@/lib/utils';
import { User } from 'lucide-react';
interface AvatarProps {
src?: string | null;
alt?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
const sizeMap = {
sm: 32,
md: 40,
lg: 56,
xl: 96,
};
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-14 h-14',
xl: 'w-24 h-24',
};
export function Avatar({ src, alt = 'Avatar', size = 'md', className }: AvatarProps) {
const dimension = sizeMap[size];
const sizeClass = sizeClasses[size];
if (!src) {
return (
<div
className={cn(
'rounded-full bg-background-tertiary border border-border flex items-center justify-center text-foreground-muted',
sizeClass,
className
)}
>
<User className={size === 'sm' ? 'w-4 h-4' : size === 'md' ? 'w-5 h-5' : size === 'lg' ? 'w-7 h-7' : 'w-12 h-12'} />
</div>
);
}
return (
<div className={cn('relative rounded-full overflow-hidden', sizeClass, className)}>
<Image
src={src}
alt={alt}
width={dimension}
height={dimension}
className="object-cover w-full h-full"
unoptimized
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { forwardRef, ButtonHTMLAttributes, ReactNode } from 'react';
import { motion } from 'framer-motion';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================================
// 类型定义
// ============================================================================
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
children?: ReactNode;
}
// ============================================================================
// 样式配置
// ============================================================================
const variantStyles: Record<ButtonVariant, string> = {
primary: `
bg-primary text-background font-medium
hover:bg-primary-hover hover:shadow-glow
disabled:hover:shadow-none
`,
secondary: `
bg-background-tertiary text-foreground font-medium
border border-border
hover:bg-background-elevated hover:border-border-hover
`,
ghost: `
text-foreground-muted font-medium
hover:text-foreground hover:bg-background-tertiary
`,
danger: `
bg-accent-red/10 text-accent-red font-medium
border border-accent-red/20
hover:bg-accent-red/20 hover:border-accent-red/30
`,
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-4 py-2 text-sm rounded-lg gap-1.5',
md: 'px-6 py-3 text-base rounded-xl gap-2',
lg: 'px-8 py-4 text-lg rounded-xl gap-2.5',
};
// ============================================================================
// 组件
// ============================================================================
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon,
rightIcon,
disabled,
children,
type = 'button',
...props
},
ref
) => {
return (
<motion.button
ref={ref}
type={type}
className={cn(
'inline-flex items-center justify-center',
'transition-all duration-200 ease-out',
'active:scale-[0.98]',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
variantStyles[variant],
sizeStyles[size],
className
)}
disabled={disabled || isLoading}
whileTap={{ scale: disabled || isLoading ? 1 : 0.98 }}
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
leftIcon
)}
<span>{children}</span>
{!isLoading && rightIcon}
</motion.button>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,109 @@
'use client';
import { HTMLAttributes, forwardRef, ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
// ============================================================================
// 类型定义
// ============================================================================
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'elevated' | 'glass';
hover?: boolean;
children?: ReactNode;
}
// ============================================================================
// 组件
// ============================================================================
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', hover = false, children, ...props }, ref) => {
const variantStyles = {
default: 'bg-background-secondary border-border',
elevated: 'bg-background-elevated border-border shadow-lg',
glass: 'bg-background-secondary/60 backdrop-blur-xl border-white/5',
};
// 过滤掉非 motion.div 属性,避免类型冲突
const { style, id, ...rest } = props;
return (
<motion.div
ref={ref}
id={id}
style={style}
className={cn(
'rounded-2xl border transition-all duration-300',
variantStyles[variant],
hover && 'hover:border-border-hover hover:shadow-lg hover:shadow-primary/5',
className
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
{children}
</motion.div>
);
}
);
Card.displayName = 'Card';
// ============================================================================
// 子组件
// ============================================================================
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('px-6 py-5 border-b border-border', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-foreground-muted mt-1', className)}
{...props}
/>
)
);
CardDescription.displayName = 'CardDescription';
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('px-6 py-4 border-t border-border', className)}
{...props}
/>
)
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,118 @@
'use client';
import { forwardRef, InputHTMLAttributes, useState } from 'react';
import { Eye, EyeOff, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================================
// 类型定义
// ============================================================================
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
// ============================================================================
// 组件
// ============================================================================
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
className,
type = 'text',
label,
error,
hint,
leftIcon,
rightIcon,
disabled,
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === 'password';
const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-foreground-muted mb-2">
{label}
</label>
)}
<div className="relative">
{leftIcon && (
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-foreground-subtle">
{leftIcon}
</div>
)}
<input
ref={ref}
type={inputType}
className={cn(
'w-full px-4 py-3 rounded-xl',
'bg-background-tertiary text-foreground',
'border transition-all duration-200 ease-out',
'placeholder:text-foreground-subtle',
'focus:outline-none focus:ring-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
error
? 'border-accent-red focus:border-accent-red focus:ring-accent-red/20'
: 'border-border focus:border-primary focus:ring-primary/20',
leftIcon && 'pl-12',
(rightIcon || isPassword) && 'pr-12',
className
)}
disabled={disabled}
{...props}
/>
{isPassword && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
)}
{rightIcon && !isPassword && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-subtle">
{rightIcon}
</div>
)}
</div>
{error && (
<p className="mt-1.5 text-sm text-accent-red flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{error}
</p>
)}
{hint && !error && (
<p className="mt-1.5 text-sm text-foreground-subtle">
{hint}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,83 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
interface LogoProps {
size?: 'sm' | 'md' | 'lg';
showText?: boolean;
className?: string;
}
export function Logo({ size = 'md', showText = true, className }: LogoProps) {
const sizes = {
sm: { icon: 32, text: 'text-lg' },
md: { icon: 40, text: 'text-xl' },
lg: { icon: 56, text: 'text-3xl' },
};
const { icon, text } = sizes[size];
return (
<motion.div
className={cn('flex items-center gap-3', className)}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
{/* Logo Icon */}
<div className="relative">
<svg
width={icon}
height={icon}
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="drop-shadow-lg"
>
{/* 外圈 */}
<circle
cx="24"
cy="24"
r="22"
stroke="url(#logoGradient)"
strokeWidth="2"
fill="none"
/>
{/* 内部图形 - S 形状的抽象表示 */}
<path
d="M16 18C16 14.6863 18.6863 12 22 12H26C29.3137 12 32 14.6863 32 18C32 21.3137 29.3137 24 26 24H22C18.6863 24 16 26.6863 16 30C16 33.3137 18.6863 36 22 36H26C29.3137 36 32 33.3137 32 30"
stroke="url(#logoGradient)"
strokeWidth="2.5"
strokeLinecap="round"
fill="none"
/>
{/* 点缀 */}
<circle cx="24" cy="24" r="3" fill="url(#logoGradient)" />
<defs>
<linearGradient id="logoGradient" x1="4" y1="4" x2="44" y2="44" gradientUnits="userSpaceOnUse">
<stop stopColor="#7aa2f7" />
<stop offset="0.5" stopColor="#bb9af7" />
<stop offset="1" stopColor="#7dcfff" />
</linearGradient>
</defs>
</svg>
{/* 光晕效果 */}
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl -z-10" />
</div>
{/* Logo Text */}
{showText && (
<span className={cn('font-bold tracking-tight', text)}>
<span className="text-gradient">Sato</span>
<span className="text-foreground">Nano</span>
</span>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { cn } from '@/lib/utils';
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-2',
lg: 'w-12 h-12 border-3',
};
export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<div
className={cn(
'rounded-full border-primary/30 border-t-primary animate-spin',
sizeClasses[size],
className
)}
/>
);
}
export function PageLoader() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Spinner size="lg" />
<p className="text-foreground-muted text-sm">...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export { Button } from './Button';
export { Input } from './Input';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export { Logo } from './Logo';
export { Avatar } from './Avatar';
export { Alert } from './Alert';
export { Spinner, PageLoader } from './Spinner';

255
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,255 @@
/**
* API 服务层
*
* 封装所有 HTTP 请求,处理认证、错误和响应格式。
*/
// ============================================================================
// 类型定义
// ============================================================================
/** API 响应基础结构 */
export interface ApiResponse<T = unknown> {
success: boolean;
message: string;
data: T;
code?: string;
details?: Record<string, unknown>;
}
/** 用户信息 */
export interface User {
id: string;
username: string;
email: string | null;
nickname: string | null;
avatar_url: string | null;
bio: string | null;
is_active: boolean;
created_at: string;
last_login_at: string | null;
}
/** 令牌响应 */
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
is_new_user?: boolean;
}
/** 登录请求 */
export interface LoginRequest {
username: string;
password: string;
}
/** 注册请求 */
export interface RegisterRequest {
username: string;
password: string;
email?: string;
nickname?: string;
}
/** 更新用户请求 */
export interface UpdateUserRequest {
nickname?: string;
email?: string;
avatar_url?: string;
bio?: string;
}
/** 修改密码请求 */
export interface ChangePasswordRequest {
current_password: string;
new_password: string;
}
/** OAuth2 授权响应 */
export interface OAuth2AuthorizeResponse {
authorize_url: string;
state: string;
}
// ============================================================================
// 配置
// ============================================================================
const API_BASE = '/api/v1';
// ============================================================================
// 令牌管理
// ============================================================================
const TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
export const tokenStorage = {
getAccessToken: (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
},
getRefreshToken: (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
},
setTokens: (accessToken: string, refreshToken: string): void => {
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
},
clearTokens: (): void => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
},
};
// ============================================================================
// HTTP 客户端
// ============================================================================
class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: Record<string, unknown>
) {
super(message);
this.name = 'ApiError';
}
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const url = `${API_BASE}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
// 添加认证头
const token = tokenStorage.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
headers,
});
const data = await response.json();
if (!response.ok) {
// 如果是 401 错误且不是刷新令牌请求,尝试刷新令牌
if (response.status === 401 && !endpoint.includes('/refresh')) {
const refreshToken = tokenStorage.getRefreshToken();
if (refreshToken) {
try {
const refreshResult = await authApi.refresh(refreshToken);
tokenStorage.setTokens(
refreshResult.data.access_token,
refreshResult.data.refresh_token
);
// 重试原请求
headers['Authorization'] = `Bearer ${refreshResult.data.access_token}`;
const retryResponse = await fetch(url, { ...options, headers });
return retryResponse.json();
} catch {
// 刷新失败,清除令牌
tokenStorage.clearTokens();
}
}
}
throw new ApiError(
response.status,
data.code || 'UNKNOWN_ERROR',
data.message || '请求失败',
data.details
);
}
return data;
}
// ============================================================================
// API 方法
// ============================================================================
/** 认证相关 API */
export const authApi = {
/** 用户登录 */
login: (data: LoginRequest) =>
request<TokenResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(data),
}),
/** 用户注册 */
register: (data: RegisterRequest) =>
request<User>('/auth/register', {
method: 'POST',
body: JSON.stringify(data),
}),
/** 刷新令牌 */
refresh: (refreshToken: string) =>
request<TokenResponse>('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken }),
}),
/** 退出登录 */
logout: () =>
request<null>('/auth/logout', {
method: 'POST',
}),
/** 修改密码 */
changePassword: (data: ChangePasswordRequest) =>
request<null>('/auth/change-password', {
method: 'POST',
body: JSON.stringify(data),
}),
};
/** OAuth2 相关 API */
export const oauth2Api = {
/** 获取授权 URL */
getAuthorizeUrl: () =>
request<OAuth2AuthorizeResponse>('/auth/oauth2/authorize'),
/** 处理回调(通常由后端重定向处理) */
callback: (code: string, state: string) =>
request<TokenResponse>(`/auth/oauth2/callback?code=${code}&state=${state}`),
};
/** 用户相关 API */
export const userApi = {
/** 获取当前用户 */
getCurrentUser: () =>
request<User>('/users/me'),
/** 更新当前用户 */
updateCurrentUser: (data: UpdateUserRequest) =>
request<User>('/users/me', {
method: 'PATCH',
body: JSON.stringify(data),
}),
/** 获取指定用户 */
getUser: (userId: string) =>
request<User>(`/users/${userId}`),
};
export { ApiError };

146
frontend/src/lib/store.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* 全局状态管理
*
* 使用 Zustand 管理用户认证状态。
*/
import { create } from 'zustand';
import { User, authApi, userApi, tokenStorage, ApiError, UpdateUserRequest } from './api';
// ============================================================================
// 类型定义
// ============================================================================
interface AuthState {
// 状态
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
error: string | null;
// 操作
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string, email?: string, nickname?: string) => Promise<void>;
logout: () => Promise<void>;
fetchUser: () => Promise<void>;
updateUser: (data: Partial<User>) => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
clearError: () => void;
setTokensAndFetchUser: (accessToken: string, refreshToken: string) => Promise<void>;
}
// ============================================================================
// Store
// ============================================================================
export const useAuthStore = create<AuthState>((set, get) => ({
// 初始状态
user: null,
isLoading: true,
isAuthenticated: false,
error: null,
// 登录
login: async (username, password) => {
set({ isLoading: true, error: null });
try {
const response = await authApi.login({ username, password });
tokenStorage.setTokens(response.data.access_token, response.data.refresh_token);
await get().fetchUser();
} catch (err) {
const message = err instanceof ApiError ? err.message : '登录失败';
set({ error: message, isLoading: false });
throw err;
}
},
// 注册
register: async (username, password, email, nickname) => {
set({ isLoading: true, error: null });
try {
await authApi.register({ username, password, email, nickname });
// 注册成功后自动登录
await get().login(username, password);
} catch (err) {
const message = err instanceof ApiError ? err.message : '注册失败';
set({ error: message, isLoading: false });
throw err;
}
},
// 退出
logout: async () => {
try {
await authApi.logout();
} catch {
// 忽略退出时的错误
} finally {
tokenStorage.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
// 获取用户信息
fetchUser: async () => {
const token = tokenStorage.getAccessToken();
if (!token) {
set({ user: null, isAuthenticated: false, isLoading: false });
return;
}
set({ isLoading: true });
try {
const response = await userApi.getCurrentUser();
set({ user: response.data, isAuthenticated: true, isLoading: false });
} catch {
tokenStorage.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
// 更新用户
updateUser: async (data) => {
set({ isLoading: true, error: null });
try {
// 转换 null 为 undefined
const updateData: UpdateUserRequest = {
nickname: data.nickname ?? undefined,
email: data.email ?? undefined,
avatar_url: data.avatar_url ?? undefined,
bio: data.bio ?? undefined,
};
const response = await userApi.updateCurrentUser(updateData);
set({ user: response.data, isLoading: false });
} catch (err) {
const message = err instanceof ApiError ? err.message : '更新失败';
set({ error: message, isLoading: false });
throw err;
}
},
// 修改密码
changePassword: async (currentPassword, newPassword) => {
set({ isLoading: true, error: null });
try {
await authApi.changePassword({
current_password: currentPassword,
new_password: newPassword,
});
set({ isLoading: false });
} catch (err) {
const message = err instanceof ApiError ? err.message : '修改密码失败';
set({ error: message, isLoading: false });
throw err;
}
},
// 清除错误
clearError: () => set({ error: null }),
// OAuth2 登录后设置令牌并获取用户
setTokensAndFetchUser: async (accessToken, refreshToken) => {
tokenStorage.setTokens(accessToken, refreshToken);
await get().fetchUser();
},
}));

45
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* 工具函数
*/
import { clsx, type ClassValue } from 'clsx';
/**
* 合并 className
*/
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
/**
* 格式化日期
*/
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
/**
* 获取用户头像 URL 或默认头像
*/
export function getAvatarUrl(user: { avatar_url?: string | null; username: string }): string {
if (user.avatar_url) {
return user.avatar_url;
}
// 使用 UI Avatars 生成默认头像
return `https://ui-avatars.com/api/?name=${encodeURIComponent(user.username)}&background=7aa2f7&color=0f0f14&bold=true`;
}
/**
* 获取显示名称
*/
export function getDisplayName(user: { nickname?: string | null; username: string }): string {
return user.nickname || user.username;
}

View File

@@ -0,0 +1,85 @@
/**
* 表单验证 Schema
*/
import { z } from 'zod';
// ============================================================================
// 密码验证
// ============================================================================
export const passwordSchema = z
.string()
.min(8, '密码至少需要 8 个字符')
.max(128, '密码最多 128 个字符')
.regex(/[A-Z]/, '密码需要包含至少一个大写字母')
.regex(/[a-z]/, '密码需要包含至少一个小写字母')
.regex(/[0-9]/, '密码需要包含至少一个数字');
// ============================================================================
// 用户名验证
// ============================================================================
export const usernameSchema = z
.string()
.min(3, '用户名至少需要 3 个字符')
.max(32, '用户名最多 32 个字符')
.regex(/^[a-zA-Z]/, '用户名必须以字母开头')
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线');
// ============================================================================
// 登录表单
// ============================================================================
export const loginSchema = z.object({
username: z.string().min(1, '请输入用户名或邮箱'),
password: z.string().min(1, '请输入密码'),
});
export type LoginFormData = z.infer<typeof loginSchema>;
// ============================================================================
// 注册表单
// ============================================================================
export const registerSchema = z.object({
username: usernameSchema,
password: passwordSchema,
confirmPassword: z.string(),
email: z.string().email('请输入有效的邮箱地址').optional().or(z.literal('')),
nickname: z.string().max(64, '昵称最多 64 个字符').optional().or(z.literal('')),
}).refine((data) => data.password === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword'],
});
export type RegisterFormData = z.infer<typeof registerSchema>;
// ============================================================================
// 修改密码表单
// ============================================================================
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, '请输入当前密码'),
newPassword: passwordSchema,
confirmPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword'],
});
export type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
// ============================================================================
// 更新用户资料表单
// ============================================================================
export const updateProfileSchema = z.object({
nickname: z.string().max(64, '昵称最多 64 个字符').optional().or(z.literal('')),
email: z.string().email('请输入有效的邮箱地址').optional().or(z.literal('')),
bio: z.string().max(500, '简介最多 500 个字符').optional().or(z.literal('')),
avatar_url: z.string().url('请输入有效的 URL').max(512).optional().or(z.literal('')),
});
export type UpdateProfileFormData = z.infer<typeof updateProfileSchema>;