diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx new file mode 100644 index 0000000..86bac3a --- /dev/null +++ b/web/src/components/RequireAuth.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet } from 'react-router-dom' +import { useAuth } from '@/stores/auth' + +const RequireAuth = () => { + const { user } = useAuth() + if (!user) return + return +} + +export default RequireAuth diff --git a/web/src/main.tsx b/web/src/main.tsx index 5b10795..8c9b84e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( - + + + , ) diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..76c7283 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -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 ( +
+ +
+
+ +
+

客服云

+

在线客服系统

+
+
+ + + + + + + + + +
+
+
+ ) +} + +export default Login diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index c4c38c4..e56e6bc 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -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: , - }, - { - path: '/admin', - element: , - children: [ - { index: true, element: }, - { path: 'dashboard', element: }, - { path: 'tenants', element: }, - { path: 'plans', element: }, - { path: 'ops', element: }, - ], - }, + { path: '/login', element: }, + { path: '/widget/preview', element: }, { path: '/', - element: , + element: , children: [ + { + path: 'admin', + element: , + children: [ + { index: true, element: }, + { path: 'dashboard', element: }, + { path: 'tenants', element: }, + { path: 'plans', element: }, + { path: 'ops', element: }, + ], + }, { index: true, element: }, { path: 'agent', + element: , children: [ { index: true, element: }, { path: 'dashboard', element: }, diff --git a/web/src/services/api.ts b/web/src/services/api.ts new file mode 100644 index 0000000..ea29e5c --- /dev/null +++ b/web/src/services/api.ts @@ -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('/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(`/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(`/customers?${search}`) +} + +// Knowledge +export const getKnowledgeCategories = () => get('/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(`/knowledge/entries?${search}`) +} + +// Statistics +export const getKPIs = () => get>('/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(`/admin/tenants?${search}`) +} +export const getAdminStats = () => get('/admin/stats') diff --git a/web/src/services/request.ts b/web/src/services/request.ts new file mode 100644 index 0000000..3a2de31 --- /dev/null +++ b/web/src/services/request.ts @@ -0,0 +1,46 @@ +const BASE = '/api' + +let token = '' + +export function setToken(t: string) { token = t } +export function getToken() { return token } + +interface Response { + code: number + message: string + data: T +} + +interface ListResponse { + code: number + message: string + list: T[] + total: number + page: number + pageSize: number +} + +async function request(url: string, options: RequestInit = {}): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...((options.headers as Record) || {}), + } + 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 = (url: string) => request>(url) +export const post = (url: string, data: unknown) => request>(url, { method: 'POST', body: JSON.stringify(data) }) +export const put = (url: string, data: unknown) => request>(url, { method: 'PUT', body: JSON.stringify(data) }) +export const del = (url: string) => request>(url, { method: 'DELETE' }) +export const getList = (url: string) => request>(url) diff --git a/web/src/stores/auth.tsx b/web/src/stores/auth.tsx new file mode 100644 index 0000000..5564621 --- /dev/null +++ b/web/src/stores/auth.tsx @@ -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 + logout: () => void +} + +const AuthContext = createContext({ + user: null, loading: false, + login: async () => {}, logout: () => {}, +}) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(() => { + 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 ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/web/vite.config.ts b/web/vite.config.ts index b056cc4..a227db8 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -10,4 +10,10 @@ export default defineConfig({ '@': path.resolve(__dirname, 'src'), }, }, + server: { + proxy: { + '/api': 'http://localhost:8080', + '/health': 'http://localhost:8080', + }, + }, })