配置Vite代理、API请求封装、JWT认证上下文、登录页面、路由鉴权

This commit is contained in:
yml2213
2026-07-14 11:30:33 +08:00
parent a0ced7bdde
commit 6d437b4e82
8 changed files with 264 additions and 17 deletions
+10
View File
@@ -0,0 +1,10 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '@/stores/auth'
const RequireAuth = () => {
const { user } = useAuth()
if (!user) return <Navigate to="/login" replace />
return <Outlet />
}
export default RequireAuth
+4 -1
View File
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import App from './App'
import { AuthProvider } from './stores/auth'
import './index.css'
const theme = {
@@ -16,7 +17,9 @@ const theme = {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider theme={theme} locale={zhCN}>
<App />
<AuthProvider>
<App />
</AuthProvider>
</ConfigProvider>
</React.StrictMode>,
)
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react'
import { Form, Input, Button, Card, message } from 'antd'
import { MessageOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/stores/auth'
const Login = () => {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true)
try {
await login(values.username, values.password)
message.success('登录成功')
navigate('/agent/dashboard', { replace: true })
} catch {
message.error('用户名或密码错误')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-neutral-100 flex items-center justify-center p-4">
<Card className="w-full max-w-[400px] shadow-lg !rounded-xl" bordered={false}>
<div className="text-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center mx-auto mb-3">
<MessageOutlined className="text-white text-xl" />
</div>
<h1 className="text-xl font-bold text-neutral-800"></h1>
<p className="text-sm text-neutral-400 mt-1">线</p>
</div>
<Form form={form} layout="vertical" onFinish={onFinish} autoComplete="off">
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="admin / agent1" size="large" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="password123" size="large" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default Login
+17 -16
View File
@@ -1,9 +1,11 @@
import { lazy, Suspense } from 'react'
import { Navigate, createBrowserRouter } from 'react-router-dom'
import { Spin } from 'antd'
import RequireAuth from '@/components/RequireAuth'
const AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout'))
const Login = lazy(() => import('@/pages/Login'))
const Dashboard = lazy(() => import('@/pages/agent/Dashboard'))
const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
const Customers = lazy(() => import('@/pages/agent/Customers'))
@@ -27,28 +29,27 @@ function Lazy({ children }: { children: React.ReactNode }) {
}
export const router = createBrowserRouter([
{
path: '/widget/preview',
element: <Lazy><WidgetPreview /></Lazy>,
},
{
path: '/admin',
element: <Lazy><AdminLayout /></Lazy>,
children: [
{ index: true, element: <Navigate to="/admin/dashboard" replace /> },
{ path: 'dashboard', element: <Lazy><AdminDashboard /></Lazy> },
{ path: 'tenants', element: <Lazy><Tenants /></Lazy> },
{ path: 'plans', element: <Lazy><Plans /></Lazy> },
{ path: 'ops', element: <Lazy><Ops /></Lazy> },
],
},
{ path: '/login', element: <Lazy><Login /></Lazy> },
{ path: '/widget/preview', element: <Lazy><WidgetPreview /></Lazy> },
{
path: '/',
element: <Lazy><AgentLayout /></Lazy>,
element: <RequireAuth />,
children: [
{
path: 'admin',
element: <Lazy><AdminLayout /></Lazy>,
children: [
{ index: true, element: <Navigate to="/admin/dashboard" replace /> },
{ path: 'dashboard', element: <Lazy><AdminDashboard /></Lazy> },
{ path: 'tenants', element: <Lazy><Tenants /></Lazy> },
{ path: 'plans', element: <Lazy><Plans /></Lazy> },
{ path: 'ops', element: <Lazy><Ops /></Lazy> },
],
},
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
{
path: 'agent',
element: <Lazy><AgentLayout /></Lazy>,
children: [
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
{ path: 'dashboard', element: <Lazy><Dashboard /></Lazy> },
+72
View File
@@ -0,0 +1,72 @@
import { get, post, getList } from './request'
export interface LoginParams { username: string; password: string }
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
export interface Session {
id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null
status: string; priority: string; satisfaction_score: number | null; created_at: string; ended_at: string | null
}
export interface Customer {
id: number; tenant_id: number; name: string; phone: string; email: string; tags: string
source: string; status: string; conversation_count: number; last_contact_at: string
}
export interface KnowledgeEntry {
id: number; title: string; content: string; status: string; usage_count: number
category_id: number; updated_at: string
}
export interface Tenant {
id: number; name: string; plan_id: number; seat_count: number; expire_at: string; status: string
contact_name: string; contact_phone: string; contact_email: string
}
// Auth
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
// Sessions
export const getSessions = (params?: { status?: string; priority?: string; page?: number }) => {
const search = new URLSearchParams()
if (params?.status) search.set('status', params.status)
if (params?.priority) search.set('priority', params.priority)
if (params?.page) search.set('page', String(params.page))
return getList<Session>(`/sessions?${search}`)
}
export const getSession = (id: number) => get<{ session: Session; messages: unknown[] }>(`/sessions/${id}`)
export const assignSession = (id: number, agentId: number) => post(`/sessions/${id}/assign`, { agent_id: agentId })
export const endSession = (id: number, reason: string) => post(`/sessions/${id}/end?reason=${reason}`, {})
// Customers
export const getCustomers = (params?: { search?: string; status?: string; page?: number }) => {
const search = new URLSearchParams()
if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status)
if (params?.page) search.set('page', String(params.page || 1))
return getList<Customer>(`/customers?${search}`)
}
// Knowledge
export const getKnowledgeCategories = () => get<unknown[]>('/knowledge/categories')
export const getKnowledgeEntries = (params?: { category_id?: string; search?: string; page?: number }) => {
const search = new URLSearchParams()
if (params?.category_id) search.set('category_id', params.category_id)
if (params?.search) search.set('search', params.search)
if (params?.page) search.set('page', String(params.page || 1))
return getList<KnowledgeEntry>(`/knowledge/entries?${search}`)
}
// Statistics
export const getKPIs = () => get<Record<string, number>>('/statistics/kpi')
export const getSessionTrend = () => get<{ date: string; count: number }[]>('/statistics/trend')
// Admin
export const getTenants = (params?: { search?: string; status?: string; page?: number }) => {
const search = new URLSearchParams()
if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status)
if (params?.page) search.set('page', String(params.page || 1))
return getList<Tenant>(`/admin/tenants?${search}`)
}
export const getAdminStats = () => get('/admin/stats')
+46
View File
@@ -0,0 +1,46 @@
const BASE = '/api'
let token = ''
export function setToken(t: string) { token = t }
export function getToken() { return token }
interface Response<T = unknown> {
code: number
message: string
data: T
}
interface ListResponse<T = unknown> {
code: number
message: string
list: T[]
total: number
page: number
pageSize: number
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${BASE}${url}`, { ...options, headers })
const json = await res.json()
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { code: number }
err.code = json.code
throw err
}
return json
}
export const get = <T>(url: string) => request<Response<T>>(url)
export const post = <T>(url: string, data: unknown) => request<Response<T>>(url, { method: 'POST', body: JSON.stringify(data) })
export const put = <T>(url: string, data: unknown) => request<Response<T>>(url, { method: 'PUT', body: JSON.stringify(data) })
export const del = <T>(url: string) => request<Response<T>>(url, { method: 'DELETE' })
export const getList = <T>(url: string) => request<ListResponse<T>>(url)
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { setToken } from '@/services/request'
import { login as loginApi, type LoginResult } from '@/services/api'
interface AuthState {
user: LoginResult | null
loading: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthState>({
user: null, loading: false,
login: async () => {}, logout: () => {},
})
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<LoginResult | null>(() => {
const saved = localStorage.getItem('auth_user')
if (saved) {
const u = JSON.parse(saved) as LoginResult
setToken(u.token)
return u
}
return null
})
const [loading, setLoading] = useState(false)
const login = useCallback(async (username: string, password: string) => {
setLoading(true)
try {
const res = await loginApi({ username, password })
const u = res.data
setToken(u.token)
setUser(u)
localStorage.setItem('auth_user', JSON.stringify(u))
} finally {
setLoading(false)
}
}, [])
const logout = useCallback(() => {
setToken('')
setUser(null)
localStorage.removeItem('auth_user')
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)