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',
+ },
+ },
})