feat: add frontend auth shell
This commit is contained in:
@@ -1,7 +1,37 @@
|
|||||||
import { AppShell } from './layout/AppShell'
|
import { AppShell } from './layout/AppShell'
|
||||||
import { Router } from './router'
|
import { Router } from './router'
|
||||||
|
import { AuthProvider, useAuth } from '@/features/auth/AuthProvider'
|
||||||
|
import { LoginPage } from '@/features/auth/LoginPage'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { authenticated, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-background px-5">
|
||||||
|
<div className="grid w-full max-w-[420px] gap-3">
|
||||||
|
<Skeleton className="h-9 w-44" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="mt-4 h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
return <LoginPage />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Router />
|
<Router />
|
||||||
|
|||||||
@@ -1,32 +1,66 @@
|
|||||||
import { Bot, BriefcaseBusiness, Database, Home, Library, Settings, ShieldCheck, Ticket } from 'lucide-react'
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
BriefcaseBusiness,
|
||||||
|
ClipboardList,
|
||||||
|
Database,
|
||||||
|
Home,
|
||||||
|
Library,
|
||||||
|
Menu,
|
||||||
|
ScrollText,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Ticket,
|
||||||
|
UserCog,
|
||||||
|
Users,
|
||||||
|
UsersRound
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { useAuth } from '@/features/auth/AuthProvider'
|
||||||
|
import type { AuthMenu } from '@/features/auth/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const navGroups = [
|
const iconMap = {
|
||||||
{
|
audit: ScrollText,
|
||||||
label: '工作台',
|
bot: Bot,
|
||||||
items: [{ href: '#/', label: '运营总览', icon: Home }]
|
briefcase: BriefcaseBusiness,
|
||||||
},
|
database: Database,
|
||||||
{
|
home: Home,
|
||||||
label: '业务处理',
|
library: Library,
|
||||||
items: [
|
menu: Menu,
|
||||||
{ href: '#/tickets', label: '工单中心', icon: Ticket },
|
roles: UserCog,
|
||||||
{ href: '#/tickets/new', label: '创建工单', icon: BriefcaseBusiness },
|
settings: Settings,
|
||||||
{ href: '#/solutions', label: '解决方案库', icon: Library }
|
shield: ShieldCheck,
|
||||||
]
|
ticket: Ticket,
|
||||||
},
|
user_groups: UsersRound,
|
||||||
{
|
users: Users
|
||||||
label: '平台能力',
|
}
|
||||||
items: [
|
|
||||||
{ href: '#/knowledge', label: '知识库', icon: Database },
|
function getIcon(icon: string) {
|
||||||
{ href: '#/agents', label: 'Agent 配置', icon: Bot },
|
return iconMap[icon as keyof typeof iconMap] ?? ClipboardList
|
||||||
{ href: '#/system', label: '系统治理', icon: ShieldCheck },
|
}
|
||||||
{ href: '#/design-system', label: '设计系统', icon: Settings }
|
|
||||||
]
|
function useCurrentHash() {
|
||||||
}
|
const [hash, setHash] = React.useState(() => window.location.hash || '#/')
|
||||||
]
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleHashChange = () => setHash(window.location.hash || '#/')
|
||||||
|
window.addEventListener('hashchange', handleHashChange)
|
||||||
|
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleChildren(menu: AuthMenu) {
|
||||||
|
return menu.children.filter((item) => item.is_visible && item.is_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const currentHash = useCurrentHash()
|
||||||
|
const groups = (user?.menus ?? []).filter((menu) => menu.is_visible && menu.is_enabled)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface lg:block">
|
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface lg:block">
|
||||||
<div className="border-b border-border px-5 py-4">
|
<div className="border-b border-border px-5 py-4">
|
||||||
@@ -34,23 +68,25 @@ export function AppSidebar() {
|
|||||||
<p className="mt-1 text-xs text-muted-foreground">企业技术支持工作台</p>
|
<p className="mt-1 text-xs text-muted-foreground">企业技术支持工作台</p>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-col gap-5 p-4">
|
<nav className="flex flex-col gap-5 p-4">
|
||||||
{navGroups.map((group) => (
|
{groups.map((group) => (
|
||||||
<div key={group.label} className="grid gap-1.5">
|
<div key={group.code} className="grid gap-1.5">
|
||||||
<p className="px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
<p className="px-2 text-xs font-medium text-muted-foreground">{group.title}</p>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{group.items.map((item) => {
|
{visibleChildren(group).map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = getIcon(item.icon)
|
||||||
|
const active = currentHash === item.path
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 items-center gap-2 rounded-md px-2.5 text-sm text-muted-foreground transition-colors',
|
'flex h-9 items-center gap-2 rounded-md px-2.5 text-sm text-muted-foreground transition-colors',
|
||||||
'hover:bg-muted hover:text-foreground'
|
'hover:bg-muted hover:text-foreground',
|
||||||
|
active && 'bg-muted text-foreground'
|
||||||
)}
|
)}
|
||||||
href={item.href}
|
href={item.path}
|
||||||
key={item.href}
|
key={item.code}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.title}</span>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Bell, ChevronRight, UserRound } from 'lucide-react'
|
import { Bell, ChevronRight, LogOut, UserRound } from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useAuth } from '@/features/auth/AuthProvider'
|
||||||
|
|
||||||
export function AppTopbar() {
|
export function AppTopbar() {
|
||||||
|
const { logout, user } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-border bg-surface/95 px-6 backdrop-blur">
|
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-border bg-surface/95 px-6 backdrop-blur">
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||||
@@ -20,7 +23,11 @@ export function AppTopbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="gap-2">
|
<Button variant="ghost" className="gap-2">
|
||||||
<UserRound className="size-4" />
|
<UserRound className="size-4" />
|
||||||
<span>系统管理员</span>
|
<span>{user?.display_name || user?.username || '当前用户'}</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="gap-2" onClick={() => void logout()}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
<span>退出</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
75
frontend/src/components/ui/alert.tsx
Normal file
75
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-3 py-2 text-left text-sm [&>svg]:absolute [&>svg]:left-3 [&>svg]:top-2.5 [&>svg~*]:pl-7 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive: "border-destructive/40 bg-destructive/10 text-destructive [&_svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"mb-1 font-medium leading-none tracking-normal [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm leading-5 text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
30
frontend/src/components/ui/checkbox.tsx
Normal file
30
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded border border-input outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
125
frontend/src/components/ui/pagination.tsx
Normal file
125
frontend/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type PaginationProps = React.ComponentProps<"nav">
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: PaginationProps) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
size?: React.ComponentProps<typeof Button>["size"]
|
||||||
|
variant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
} & React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-active={isActive}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
size={size}
|
||||||
|
variant={variant ?? (isActive ? "outline" : "ghost")}
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<a {...props} />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="上一页"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
<span>上一页</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="下一页"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>下一页</span>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-8 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">更多页码</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
}
|
||||||
33
frontend/src/components/ui/switch.tsx
Normal file
33
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex shrink-0 items-center rounded-full border border-transparent outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[18px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0 data-[size=sm]:size-3 data-[size=sm]:data-[state=checked]:translate-x-2.5 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
92
frontend/src/features/auth/AuthProvider.tsx
Normal file
92
frontend/src/features/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as authApi from './api'
|
||||||
|
import type { CurrentUser } from './types'
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: CurrentUser | null
|
||||||
|
loading: boolean
|
||||||
|
authenticated: boolean
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = React.createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
type AuthProviderProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [user, setUser] = React.useState<CurrentUser | null>(null)
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const currentUser = await authApi.getCurrentUser()
|
||||||
|
setUser(currentUser)
|
||||||
|
} catch {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
|
||||||
|
authApi
|
||||||
|
.getCurrentUser()
|
||||||
|
.then((currentUser) => {
|
||||||
|
if (mounted) {
|
||||||
|
setUser(currentUser)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (mounted) {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (mounted) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogin = React.useCallback(async (username: string, password: string) => {
|
||||||
|
const currentUser = await authApi.login(username, password)
|
||||||
|
setUser(currentUser)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogout = React.useCallback(async () => {
|
||||||
|
await authApi.logout()
|
||||||
|
setUser(null)
|
||||||
|
window.location.hash = '/'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = React.useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
authenticated: Boolean(user),
|
||||||
|
login: handleLogin,
|
||||||
|
logout: handleLogout,
|
||||||
|
refresh
|
||||||
|
}),
|
||||||
|
[handleLogin, handleLogout, loading, refresh, user]
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = React.useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
111
frontend/src/features/auth/LoginPage.tsx
Normal file
111
frontend/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { AlertCircle, LockKeyhole, UserRound } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
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'
|
||||||
|
|
||||||
|
import { useAuth } from './AuthProvider'
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [username, setUsername] = React.useState('admin')
|
||||||
|
const [password, setPassword] = React.useState('admin123')
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await login(username, password)
|
||||||
|
} catch {
|
||||||
|
setError('登录失败,请检查账号、密码或后端服务状态。')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background text-foreground">
|
||||||
|
<div className="grid min-h-screen lg:grid-cols-[minmax(0,1fr)_440px]">
|
||||||
|
<section className="hidden border-r border-border bg-surface px-10 py-8 lg:flex lg:flex-col lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex h-10 items-center rounded-md border border-border bg-background px-3 text-sm font-semibold">
|
||||||
|
AI 工单平台
|
||||||
|
</div>
|
||||||
|
<div className="mt-20 max-w-2xl">
|
||||||
|
<p className="text-sm font-medium text-primary">企业后台治理基座</p>
|
||||||
|
<h1 className="mt-4 text-4xl font-semibold leading-tight tracking-normal text-foreground">
|
||||||
|
统一登录、菜单权限与工单运营入口
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-xl text-base leading-7 text-muted-foreground">
|
||||||
|
面向企业技术支持团队,先建立稳定的管理后台体验,再承接工单、知识库和智能处理能力。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid max-w-xl grid-cols-3 gap-3 text-sm">
|
||||||
|
{['用户与角色', '菜单权限', '工单治理'].map((item) => (
|
||||||
|
<div key={item} className="rounded-md border border-border bg-background px-3 py-2 text-muted-foreground">
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-screen items-center justify-center px-5 py-8">
|
||||||
|
<Card className="w-full max-w-[400px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>登录平台</CardTitle>
|
||||||
|
<CardDescription>使用管理员账号进入企业后台</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertTitle>无法登录</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="username">账号</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
className="pl-9"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="pl-9"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="mt-1 w-full" disabled={submitting}>
|
||||||
|
{submitting ? '登录中...' : '登录'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/features/auth/api.ts
Normal file
20
frontend/src/features/auth/api.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
|
import type { CurrentUser } from './types'
|
||||||
|
|
||||||
|
export async function getCurrentUser() {
|
||||||
|
return apiRequest<CurrentUser>('/accounts/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string) {
|
||||||
|
return apiRequest<CurrentUser>('/accounts/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await apiRequest<{ status: string }>('/accounts/auth/logout', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
38
frontend/src/features/auth/types.ts
Normal file
38
frontend/src/features/auth/types.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type AuthRole = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthUserGroup = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
description: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthMenu = {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
icon: string
|
||||||
|
permission_code: string
|
||||||
|
is_visible: boolean
|
||||||
|
is_enabled: boolean
|
||||||
|
children: AuthMenu[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrentUser = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
email: string
|
||||||
|
is_superuser: boolean
|
||||||
|
roles: AuthRole[]
|
||||||
|
user_groups: AuthUserGroup[]
|
||||||
|
permissions: string[]
|
||||||
|
menus: AuthMenu[]
|
||||||
|
}
|
||||||
@@ -1,17 +1,44 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api'
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api'
|
||||||
|
|
||||||
|
let csrfToken: string | null = null
|
||||||
|
|
||||||
|
async function getCsrfToken() {
|
||||||
|
if (!csrfToken) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/accounts/auth/csrf`, {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API request failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { csrfToken: string }
|
||||||
|
csrfToken = data.csrfToken
|
||||||
|
}
|
||||||
|
return csrfToken
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const headers = new Headers(init?.headers)
|
||||||
|
if (init?.body && !headers.has('content-type')) {
|
||||||
|
headers.set('content-type', 'application/json')
|
||||||
|
}
|
||||||
|
const method = init?.method?.toUpperCase() ?? 'GET'
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && !headers.has('x-csrftoken')) {
|
||||||
|
headers.set('x-csrftoken', await getCsrfToken())
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
headers: {
|
...init,
|
||||||
'content-type': 'application/json',
|
credentials: 'include',
|
||||||
...init?.headers
|
headers
|
||||||
},
|
|
||||||
...init
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed: ${response.status}`)
|
throw new Error(`API request failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>
|
return response.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user