feat: api client, auth store, login, authed shell, data-table + CRUD kit

This commit is contained in:
2026-07-07 22:15:08 +08:00
parent 95a3dee29f
commit eb617ce436
23 changed files with 1002 additions and 352 deletions
+22 -38
View File
@@ -1,59 +1,43 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import Footer from '../components/Footer'
import Header from '../components/Header'
import {
HeadContent,
Outlet,
Scripts,
createRootRouteWithContext,
} from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { Toaster } from '@/components/ui/sonner'
import appCss from '../styles.css?url'
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();`
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=stored==='dark'||(stored!=='light'&&prefersDark)?'dark':'light';document.documentElement.classList.toggle('dark',resolved==='dark');document.documentElement.style.colorScheme=resolved;}catch(e){}})();`
export const Route = createRootRoute({
interface RouterContext {
queryClient: QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Gig 管理后台' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
shellComponent: RootDocument,
component: () => <Outlet />,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="zh-CN" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<HeadContent />
</head>
<body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-[rgba(79,184,178,0.24)]">
<Header />
<body className="font-sans antialiased">
{children}
<Footer />
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Toaster position="top-center" richColors />
<Scripts />
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
import { Link, Outlet, createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { LogOut, Moon, Sun } from 'lucide-react'
import { fetchMe } from '@/api/modules/auth'
import { isAuthenticated, useAuthStore } from '@/auth/store'
import { NAV_GROUPS } from '@/components/layout/nav'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export const Route = createFileRoute('/_authed')({
beforeLoad: () => {
if (typeof window !== 'undefined' && !isAuthenticated()) {
throw redirect({ to: '/login' })
}
},
component: AuthedLayout,
})
function AuthedLayout() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const roles = useAuthStore((s) => s.roles)
const permissions = useAuthStore((s) => s.permissions)
// Re-validate the session on mount / focus; keeps roles+permissions fresh.
const me = useQuery({
queryKey: ['auth', 'me'],
queryFn: fetchMe,
refetchOnWindowFocus: true,
})
useEffect(() => {
if (me.data) {
useAuthStore
.getState()
.setSession(me.data.data, me.data.meta?.roles ?? [], me.data.meta?.permissions ?? [])
}
}, [me.data])
useEffect(() => {
if (me.isError && !isAuthenticated()) {
void navigate({ to: '/login' })
}
}, [me.isError, navigate])
const isSuper = roles.includes('super-admin')
const canSee = (permission?: string) =>
!permission || isSuper || permissions.includes(permission)
const logout = () => {
useAuthStore.getState().clear()
void navigate({ to: '/login' })
}
const toggleTheme = () => {
const dark = document.documentElement.classList.toggle('dark')
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
localStorage.setItem('theme', dark ? 'dark' : 'light')
}
return (
<div className="flex min-h-svh">
<aside className="fixed inset-y-0 left-0 z-20 flex w-56 flex-col border-r bg-sidebar text-sidebar-foreground">
<div className="flex h-14 items-center gap-2 border-b px-4 font-semibold">
<span className="flex size-6 items-center justify-center rounded bg-primary text-xs font-bold text-primary-foreground">
G
</span>
Gig
</div>
<nav className="flex-1 overflow-y-auto p-3">
{NAV_GROUPS.map((group) => {
const items = group.items.filter((item) => canSee(item.permission))
if (items.length === 0) return null
return (
<div key={group.label} className="mb-4">
<div className="px-2 pb-1.5 text-xs font-medium text-muted-foreground">
{group.label}
</div>
<div className="flex flex-col gap-0.5">
{items.map((item) => (
<Link
key={item.to}
to={item.to}
activeOptions={{ exact: item.to === '/categories' }}
className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&.active]:bg-sidebar-accent [&.active]:font-medium [&.active]:text-sidebar-accent-foreground"
>
<item.icon className="size-4 shrink-0" />
{item.label}
</Link>
))}
</div>
</div>
)
})}
</nav>
</aside>
<div className="flex min-w-0 flex-1 flex-col pl-56">
<header className="sticky top-0 z-10 flex h-14 items-center justify-end gap-2 border-b bg-background/80 px-6 backdrop-blur">
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label="切换主题">
<Sun className="size-4 dark:hidden" />
<Moon className="hidden size-4 dark:block" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="size-8">
<AvatarImage src={user?.avatar ?? undefined} alt="" />
<AvatarFallback>
{(user?.display_name ?? user?.username ?? 'A').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="flex flex-col">
<span>{user?.display_name ?? user?.username ?? '管理员'}</span>
<span className="text-xs font-normal text-muted-foreground">
{roles.join(' · ') || '—'}
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
<LogOut className="size-4" />
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authed/')({
beforeLoad: () => {
throw redirect({ to: '/categories' })
},
})
-23
View File
@@ -1,23 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: About,
})
function About() {
return (
<main className="page-wrap px-4 py-12">
<section className="island-shell rounded-2xl p-6 sm:p-8">
<p className="island-kicker mb-2">About</p>
<h1 className="display-title mb-3 text-4xl font-bold text-[var(--sea-ink)] sm:text-5xl">
A small starter with room to grow.
</h1>
<p className="m-0 max-w-3xl text-base leading-8 text-[var(--sea-ink-soft)]">
TanStack Start gives you type-safe routing, server functions, and
modern SSR defaults. Use this as a clean foundation, then layer in
your own routes, styling, and add-ons.
</p>
</section>
</main>
)
}
-87
View File
@@ -1,87 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({ component: App })
function App() {
return (
<main className="page-wrap px-4 pb-8 pt-14">
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-14">
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_66%)]" />
<p className="island-kicker mb-3">TanStack Start Base Template</p>
<h1 className="display-title mb-5 max-w-3xl text-4xl leading-[1.02] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl">
Start simple, ship quickly.
</h1>
<p className="mb-8 max-w-2xl text-base text-[var(--sea-ink-soft)] sm:text-lg">
This base starter intentionally keeps things light: two routes, clean
structure, and the essentials you need to build from scratch.
</p>
<div className="flex flex-wrap gap-3">
<a
href="/about"
className="rounded-full border border-[rgba(50,143,151,0.3)] bg-[rgba(79,184,178,0.14)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.24)]"
>
About This Starter
</a>
<a
href="https://tanstack.com/router"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-[rgba(23,58,64,0.2)] bg-white/50 px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.35)]"
>
Router Guide
</a>
</div>
</section>
<section className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
[
'Type-Safe Routing',
'Routes and links stay in sync across every page.',
],
[
'Server Functions',
'Call server code from your UI without creating API boilerplate.',
],
[
'Streaming by Default',
'Ship progressively rendered responses for faster experiences.',
],
[
'Tailwind Native',
'Design quickly with utility-first styling and reusable tokens.',
],
].map(([title, desc], index) => (
<article
key={title}
className="island-shell feature-card rise-in rounded-2xl p-5"
style={{ animationDelay: `${index * 90 + 80}ms` }}
>
<h2 className="mb-2 text-base font-semibold text-[var(--sea-ink)]">
{title}
</h2>
<p className="m-0 text-sm text-[var(--sea-ink-soft)]">{desc}</p>
</article>
))}
</section>
<section className="island-shell mt-8 rounded-2xl p-6">
<p className="island-kicker mb-2">Quick Start</p>
<ul className="m-0 list-disc space-y-2 pl-5 text-sm text-[var(--sea-ink-soft)]">
<li>
Edit <code>src/routes/index.tsx</code> to customize the home page.
</li>
<li>
Update <code>src/components/Header.tsx</code> and{' '}
<code>src/components/Footer.tsx</code> for brand links.
</li>
<li>
Add routes in <code>src/routes</code> and tweak visual tokens in{' '}
<code>src/styles.css</code>.
</li>
</ul>
</section>
</main>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useState } from 'react'
import { toast } from 'sonner'
import { loginWithPassword, fetchMe } from '@/api/modules/auth'
import { ApiError } from '@/api/client'
import { useAuthStore, isAuthenticated } from '@/auth/store'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export const Route = createFileRoute('/login')({
beforeLoad: () => {
if (typeof window !== 'undefined' && isAuthenticated()) {
throw redirect({ to: '/' })
}
},
component: LoginPage,
})
const schema = z.object({
identity: z.string().min(1, '请输入邮箱或手机号'),
password: z.string().min(1, '请输入密码'),
})
type FormValues = z.infer<typeof schema>
function LoginPage() {
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema) })
const onSubmit = handleSubmit(async (values) => {
setSubmitting(true)
try {
const grant = await loginWithPassword(values.identity, values.password)
useAuthStore.getState().setTokens(grant.data)
const me = await fetchMe()
const roles = me.meta?.roles ?? []
if (!roles.includes('admin') && !roles.includes('super-admin')) {
useAuthStore.getState().clear()
toast.error('该账号不是管理员,无法登录管理后台')
return
}
useAuthStore.getState().setSession(me.data, roles, me.meta?.permissions ?? [])
await navigate({ to: '/' })
} catch (error) {
if (error instanceof ApiError) {
toast.error(error.code === 'INVALID_CREDENTIALS' ? '账号或密码错误' : error.message)
} else {
toast.error('网络异常,请稍后重试')
}
} finally {
setSubmitting(false)
}
})
return (
<div className="flex min-h-svh items-center justify-center bg-muted/40 px-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-xl">Gig </CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="identity"> / </Label>
<Input id="identity" autoComplete="username" {...register('identity')} />
{errors.identity && (
<p className="text-sm text-destructive">{errors.identity.message}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
autoComplete="current-password"
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<Button type="submit" disabled={submitting} className="mt-2">
{submitting ? '登录中…' : '登录'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}