调整前端目录结构
This commit is contained in:
18
tone-page-web/app/(with-header-footer)/layout.tsx
Normal file
18
tone-page-web/app/(with-header-footer)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Header from "../components/Header";
|
||||
import Footer from "../components/Footer";
|
||||
|
||||
export default function LayoutWithHeaderFooter({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="flex-1 flex flex-col bg-zinc-50">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import favicon from './favicon.ico';
|
||||
import favicon from '../favicon.ico';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function Home() {
|
||||
@@ -24,7 +24,7 @@ export default function Header() {
|
||||
{ name: '特恩(TONE)', href: '/' },
|
||||
{ name: '资源', href: '/resource' },
|
||||
{ name: '博客', href: '/blog' },
|
||||
{ name: '控制台', href: '/console' },
|
||||
{ name: '控制台', href: '/console/login' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function Account() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import LoginHeader from "./LoginHeader";
|
||||
import { SendCodeFormData } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function EmailLoginMode({ onSendCode }: { onSendCode: (data: SendCodeFormData) => Promise<boolean> }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const handleSendCode = useCallback(() => {
|
||||
if (!email.trim().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||||
toast.error('请输入正确的邮箱地址');
|
||||
return;
|
||||
}
|
||||
onSendCode({
|
||||
type: 'email',
|
||||
email,
|
||||
})
|
||||
}, [email, onSendCode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader />
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">电子邮箱</Label>
|
||||
<Input
|
||||
id="email-login-mode-email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="电子邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center h-4">
|
||||
<Label htmlFor="code">验证码</Label>
|
||||
</div>
|
||||
<div className="flex gap-5">
|
||||
<InputOTP
|
||||
id="email-login-mode-code"
|
||||
name="code"
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
required
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<Button type="button" variant="secondary" onClick={handleSendCode}>获取验证码</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
注册并登录
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
12
tone-page-web/app/console/login/components/LoginHeader.tsx
Normal file
12
tone-page-web/app/console/login/components/LoginHeader.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function LoginHeader() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">欢迎回来</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
登陆到您的账户
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import LoginHeader from "./LoginHeader";
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export default function PasswordLoginMode({ forgetPassword }: { forgetPassword: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<LoginHeader />
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">电子邮箱/手机号/账号</Label>
|
||||
<Input
|
||||
id="password-login-mode-account"
|
||||
name="account"
|
||||
type="text"
|
||||
placeholder="电子邮箱/手机号/账号"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center h-4">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<a
|
||||
onClick={forgetPassword}
|
||||
className="ml-auto text-sm underline-offset-2 hover:underline cursor-pointer"
|
||||
>
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="password-login-mode-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
登录
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import LoginHeader from "./LoginHeader";
|
||||
import { SendCodeFormData } from "./types";
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export default function PhoneLoginMode({ onSendCode }: { onSendCode: (data: SendCodeFormData) => Promise<boolean> }) {
|
||||
const [phone, setPhone] = useState("");
|
||||
const handleSendCode = useCallback(() => {
|
||||
if (phone.trim().length !== 11) {
|
||||
toast.error('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
onSendCode({
|
||||
type: 'phone',
|
||||
phone,
|
||||
})
|
||||
}, [phone, onSendCode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader />
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone-login-mode-phone"
|
||||
name="phone"
|
||||
type="text"
|
||||
placeholder="+86 手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center h-4">
|
||||
<Label htmlFor="code">验证码</Label>
|
||||
</div>
|
||||
<div className="flex gap-5">
|
||||
<InputOTP
|
||||
id="phone-login-mode-code"
|
||||
name="code"
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
required
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<Button type="button" variant="secondary" onClick={handleSendCode}>获取验证码</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
注册并登录
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
BIN
tone-page-web/app/console/login/components/login-bg.jpg
Normal file
BIN
tone-page-web/app/console/login/components/login-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
16
tone-page-web/app/console/login/components/types.ts
Normal file
16
tone-page-web/app/console/login/components/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type SubmitMode = 'password' | 'phone' | 'email';
|
||||
export type LoginFormData = {
|
||||
type: SubmitMode;
|
||||
account?: string;
|
||||
password?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export type SendCodeMode = 'phone' | 'email';
|
||||
export type SendCodeFormData = {
|
||||
type: SendCodeMode;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}
|
||||
140
tone-page-web/app/console/login/page.tsx
Normal file
140
tone-page-web/app/console/login/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
import { authApi, verificationApi } from "@/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import Header from "@/app/components/Header";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { KeyRound, Phone, Mail } from "lucide-react";
|
||||
import EmailLoginMode from "./components/EmailLoginMode";
|
||||
import PasswordLoginMode from "./components/PasswordLoginMode";
|
||||
import PhoneLoginMode from "./components/PhoneLoginMode";
|
||||
import { LoginFormData, SendCodeFormData, SubmitMode } from "./components/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import LoginBG from './components/login-bg.jpg';
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [loginMode, setLoginMode] = useState<SubmitMode>('password');
|
||||
|
||||
const handleForgetPassword = useCallback(() => {
|
||||
toast.warning('开发中,敬请期待!暂时可通过发送邮件至网站管理员进行密码重置。');
|
||||
}, []);
|
||||
|
||||
const handleSendCode = async (data: SendCodeFormData) => {
|
||||
const res = await verificationApi.send({
|
||||
type: 'login',
|
||||
targetType: data.type,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
})
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
toast.success('验证码已发送,请注意查收');
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.message || '验证码发送失败,请稍后再试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: LoginFormData) => {
|
||||
const res = await authApi.login({
|
||||
...data,
|
||||
});
|
||||
|
||||
if (res.statusCode === 200 && res.data) {
|
||||
toast.success('登录成功');
|
||||
localStorage.setItem('token', res.data.token);
|
||||
router.replace('/console');
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.message || '登录失败,请稍后再试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-3xl">
|
||||
<div className={cn("flex flex-col gap-6")}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form className="p-6 md:p-8" onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
handleSubmit({
|
||||
type: loginMode,
|
||||
account: formData.get('account')?.toString(),
|
||||
password: formData.get('password')?.toString(),
|
||||
phone: formData.get('phone')?.toString(),
|
||||
email: formData.get('email')?.toString(),
|
||||
code: formData.get('code')?.toString(),
|
||||
})
|
||||
}}>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{loginMode === 'password' ? <PasswordLoginMode forgetPassword={handleForgetPassword} /> : null}
|
||||
{loginMode === 'phone' ? <PhoneLoginMode onSendCode={handleSendCode} /> : null}
|
||||
{loginMode === 'email' ? <EmailLoginMode onSendCode={handleSendCode} /> : null}
|
||||
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||
或者使用
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Button variant={loginMode === 'password' ? 'default' : 'outline'} type="button" className="w-full" onClick={() => setLoginMode('password')}>
|
||||
<KeyRound />
|
||||
</Button>
|
||||
<Button variant={loginMode === 'phone' ? 'default' : 'outline'} type="button" className="w-full" onClick={() => setLoginMode('phone')}>
|
||||
<Phone />
|
||||
</Button>
|
||||
<Button variant={loginMode === 'email' ? 'default' : 'outline'} type="button" className="w-full" onClick={() => setLoginMode('email')}>
|
||||
<Mail />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
还没有账号?{" "}
|
||||
<a className="underline underline-offset-4 cursor-pointer" onClick={() => setLoginMode('phone')}>
|
||||
注册
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="bg-muted relative hidden md:block">
|
||||
<Image
|
||||
src={LoginBG.src}
|
||||
alt="Image"
|
||||
width={500}
|
||||
height={500}
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
priority
|
||||
quality={100}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
登录即表示您同意我们的{" "}
|
||||
<a href="#" className="underline underline-offset-4">
|
||||
服务条款
|
||||
</a>{" "}
|
||||
和{" "}
|
||||
<a href="#" className="underline underline-offset-4">
|
||||
隐私政策
|
||||
</a>
|
||||
</div>
|
||||
</div >
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,52 @@
|
||||
'use client';
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
import { LoginForm, LoginFormData } from "@/components/login-form";
|
||||
|
||||
const handleSubmit = async (data: LoginFormData) => {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
const handleSendCode = async (data: LoginFormData) => {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
export default function Console() {
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-3xl">
|
||||
<LoginForm onSubmit={handleSubmit} onSendCode={handleSendCode} />
|
||||
</div>
|
||||
</div>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink href="#">
|
||||
Building Your Application
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
</div>
|
||||
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
55
tone-page-web/app/dashboard/page.tsx
Normal file
55
tone-page-web/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink href="#">
|
||||
Building Your Application
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
</div>
|
||||
<div className="bg-muted/50 min-h-[100vh] flex-1 rounded-xl md:min-h-min" />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -35,12 +35,10 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Header />
|
||||
<main className="flex-1 flex flex-col bg-zinc-50">
|
||||
{children}
|
||||
<Toaster />
|
||||
</main>
|
||||
<Footer />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user