配置Vite代理、API请求封装、JWT认证上下文、登录页面、路由鉴权
This commit is contained in:
@@ -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
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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> },
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user