- {group.items.map((item) => {
- const Icon = item.icon
+ {visibleChildren(group).map((item) => {
+ const Icon = getIcon(item.icon)
+ const active = currentHash === item.path
return (
- {item.label}
+ {item.title}
)
})}
diff --git a/frontend/src/components/app/AppTopbar.tsx b/frontend/src/components/app/AppTopbar.tsx
index 1e854f2..8e6b53c 100644
--- a/frontend/src/components/app/AppTopbar.tsx
+++ b/frontend/src/components/app/AppTopbar.tsx
@@ -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 { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
+import { useAuth } from '@/features/auth/AuthProvider'
export function AppTopbar() {
+ const { logout, user } = useAuth()
+
return (
@@ -20,7 +23,11 @@ export function AppTopbar() {
+
diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000..f7b4bda
--- /dev/null
+++ b/frontend/src/components/ui/alert.tsx
@@ -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
) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }
diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..61030a9
--- /dev/null
+++ b/frontend/src/components/ui/checkbox.tsx
@@ -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) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/frontend/src/components/ui/pagination.tsx b/frontend/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..f3f4481
--- /dev/null
+++ b/frontend/src/components/ui/pagination.tsx
@@ -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 (
+
+ )
+}
+
+function PaginationContent({
+ className,
+ ...props
+}: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+ return
+}
+
+type PaginationLinkProps = {
+ isActive?: boolean
+ size?: React.ComponentProps["size"]
+ variant?: React.ComponentProps["variant"]
+} & React.ComponentProps<"a">
+
+function PaginationLink({
+ className,
+ isActive,
+ size = "icon",
+ variant,
+ ...props
+}: PaginationLinkProps) {
+ return (
+
+ )
+}
+
+function PaginationPrevious({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ 上一页
+
+ )
+}
+
+function PaginationNext({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ 下一页
+
+
+ )
+}
+
+function PaginationEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ 更多页码
+
+ )
+}
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+}
diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx
new file mode 100644
index 0000000..a22815d
--- /dev/null
+++ b/frontend/src/components/ui/switch.tsx
@@ -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 & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/frontend/src/features/auth/AuthProvider.tsx b/frontend/src/features/auth/AuthProvider.tsx
new file mode 100644
index 0000000..1b368d8
--- /dev/null
+++ b/frontend/src/features/auth/AuthProvider.tsx
@@ -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
+ logout: () => Promise
+ refresh: () => Promise
+}
+
+const AuthContext = React.createContext(null)
+
+type AuthProviderProps = {
+ children: React.ReactNode
+}
+
+export function AuthProvider({ children }: AuthProviderProps) {
+ const [user, setUser] = React.useState(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(
+ () => ({
+ user,
+ loading,
+ authenticated: Boolean(user),
+ login: handleLogin,
+ logout: handleLogout,
+ refresh
+ }),
+ [handleLogin, handleLogout, loading, refresh, user]
+ )
+
+ return {children}
+}
+
+export function useAuth() {
+ const context = React.useContext(AuthContext)
+ if (!context) {
+ throw new Error('useAuth must be used within AuthProvider')
+ }
+ return context
+}
diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx
new file mode 100644
index 0000000..3234533
--- /dev/null
+++ b/frontend/src/features/auth/LoginPage.tsx
@@ -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) {
+ event.preventDefault()
+ setError('')
+ setSubmitting(true)
+ try {
+ await login(username, password)
+ } catch {
+ setError('登录失败,请检查账号、密码或后端服务状态。')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ AI 工单平台
+
+
+
企业后台治理基座
+
+ 统一登录、菜单权限与工单运营入口
+
+
+ 面向企业技术支持团队,先建立稳定的管理后台体验,再承接工单、知识库和智能处理能力。
+
+
+
+
+ {['用户与角色', '菜单权限', '工单治理'].map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+ 登录平台
+ 使用管理员账号进入企业后台
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts
new file mode 100644
index 0000000..dd5383b
--- /dev/null
+++ b/frontend/src/features/auth/api.ts
@@ -0,0 +1,20 @@
+import { apiRequest } from '@/lib/api'
+
+import type { CurrentUser } from './types'
+
+export async function getCurrentUser() {
+ return apiRequest('/accounts/auth/me')
+}
+
+export async function login(username: string, password: string) {
+ return apiRequest('/accounts/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ username, password })
+ })
+}
+
+export async function logout() {
+ await apiRequest<{ status: string }>('/accounts/auth/logout', {
+ method: 'POST'
+ })
+}
diff --git a/frontend/src/features/auth/types.ts b/frontend/src/features/auth/types.ts
new file mode 100644
index 0000000..b490fac
--- /dev/null
+++ b/frontend/src/features/auth/types.ts
@@ -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[]
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 8745e42..258bab8 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,17 +1,44 @@
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(path: string, init?: RequestInit): Promise {
+ 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}`, {
- headers: {
- 'content-type': 'application/json',
- ...init?.headers
- },
- ...init
+ ...init,
+ credentials: 'include',
+ headers
})
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`)
}
+ if (response.status === 204) {
+ return undefined as T
+ }
+
return response.json() as Promise
}