配置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 { ConfigProvider } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { AuthProvider } from './stores/auth'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
@@ -16,7 +17,9 @@ const theme = {
|
|||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ConfigProvider theme={theme} locale={zhCN}>
|
<ConfigProvider theme={theme} locale={zhCN}>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</React.StrictMode>,
|
</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 { lazy, Suspense } from 'react'
|
||||||
import { Navigate, createBrowserRouter } from 'react-router-dom'
|
import { Navigate, createBrowserRouter } from 'react-router-dom'
|
||||||
import { Spin } from 'antd'
|
import { Spin } from 'antd'
|
||||||
|
import RequireAuth from '@/components/RequireAuth'
|
||||||
|
|
||||||
const AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
|
const AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
|
||||||
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout'))
|
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout'))
|
||||||
|
const Login = lazy(() => import('@/pages/Login'))
|
||||||
const Dashboard = lazy(() => import('@/pages/agent/Dashboard'))
|
const Dashboard = lazy(() => import('@/pages/agent/Dashboard'))
|
||||||
const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
|
const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
|
||||||
const Customers = lazy(() => import('@/pages/agent/Customers'))
|
const Customers = lazy(() => import('@/pages/agent/Customers'))
|
||||||
@@ -27,28 +29,27 @@ function Lazy({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{ path: '/login', element: <Lazy><Login /></Lazy> },
|
||||||
path: '/widget/preview',
|
{ path: '/widget/preview', element: <Lazy><WidgetPreview /></Lazy> },
|
||||||
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: '/',
|
path: '/',
|
||||||
element: <Lazy><AgentLayout /></Lazy>,
|
element: <RequireAuth />,
|
||||||
children: [
|
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 /> },
|
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
||||||
{
|
{
|
||||||
path: 'agent',
|
path: 'agent',
|
||||||
|
element: <Lazy><AgentLayout /></Lazy>,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
||||||
{ path: 'dashboard', element: <Lazy><Dashboard /></Lazy> },
|
{ 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)
|
||||||
@@ -10,4 +10,10 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080',
|
||||||
|
'/health': 'http://localhost:8080',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user