提供基本前后端骨架
This commit is contained in:
37
frontend/src/app/dashboard/layout.tsx
Normal file
37
frontend/src/app/dashboard/layout.tsx
Normal 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>;
|
||||
}
|
||||
|
||||
152
frontend/src/app/dashboard/page.tsx
Normal file
152
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
195
frontend/src/app/dashboard/profile/page.tsx
Normal file
195
frontend/src/app/dashboard/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
181
frontend/src/app/dashboard/settings/page.tsx
Normal file
181
frontend/src/app/dashboard/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
256
frontend/src/app/globals.css
Normal file
256
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
|
||||
32
frontend/src/app/layout.tsx
Normal file
32
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
40
frontend/src/app/login/page.tsx
Normal file
40
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
57
frontend/src/app/not-found.tsx
Normal file
57
frontend/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
158
frontend/src/app/oauth2/callback/page.tsx
Normal file
158
frontend/src/app/oauth2/callback/page.tsx
Normal 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
28
frontend/src/app/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
||||
40
frontend/src/app/register/page.tsx
Normal file
40
frontend/src/app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
126
frontend/src/components/auth/AuthLayout.tsx
Normal file
126
frontend/src/components/auth/AuthLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
131
frontend/src/components/auth/LoginForm.tsx
Normal file
131
frontend/src/components/auth/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
118
frontend/src/components/auth/RegisterForm.tsx
Normal file
118
frontend/src/components/auth/RegisterForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
4
frontend/src/components/auth/index.ts
Normal file
4
frontend/src/components/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AuthLayout } from './AuthLayout';
|
||||
export { LoginForm } from './LoginForm';
|
||||
export { RegisterForm } from './RegisterForm';
|
||||
|
||||
233
frontend/src/components/dashboard/DashboardLayout.tsx
Normal file
233
frontend/src/components/dashboard/DashboardLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
frontend/src/components/dashboard/index.ts
Normal file
2
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DashboardLayout } from './DashboardLayout';
|
||||
|
||||
96
frontend/src/components/ui/Alert.tsx
Normal file
96
frontend/src/components/ui/Alert.tsx
Normal 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';
|
||||
|
||||
59
frontend/src/components/ui/Avatar.tsx
Normal file
59
frontend/src/components/ui/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
105
frontend/src/components/ui/Button.tsx
Normal file
105
frontend/src/components/ui/Button.tsx
Normal 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';
|
||||
|
||||
109
frontend/src/components/ui/Card.tsx
Normal file
109
frontend/src/components/ui/Card.tsx
Normal 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';
|
||||
|
||||
118
frontend/src/components/ui/Input.tsx
Normal file
118
frontend/src/components/ui/Input.tsx
Normal 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';
|
||||
|
||||
83
frontend/src/components/ui/Logo.tsx
Normal file
83
frontend/src/components/ui/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
38
frontend/src/components/ui/Spinner.tsx
Normal file
38
frontend/src/components/ui/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
8
frontend/src/components/ui/index.ts
Normal file
8
frontend/src/components/ui/index.ts
Normal 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
255
frontend/src/lib/api.ts
Normal 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
146
frontend/src/lib/store.ts
Normal 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
45
frontend/src/lib/utils.ts
Normal 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;
|
||||
}
|
||||
|
||||
85
frontend/src/lib/validations.ts
Normal file
85
frontend/src/lib/validations.ts
Normal 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>;
|
||||
|
||||
Reference in New Issue
Block a user