feat: add TaskAssistant page with AI chat + admin layout updates + service layer
This commit is contained in:
parent
4dad572731
commit
f552ba0619
2
package-lock.json
generated
2
package-lock.json
generated
@ -2811,7 +2811,7 @@
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ConfigProvider } from 'antd'
|
||||
@ -12,6 +12,7 @@ import AdminLayout from './layouts/AdminLayout'
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const UserManagement = lazy(() => import('./pages/UserManagement'))
|
||||
const TaskAssistant = lazy(() => import('./pages/TaskAssistant'))
|
||||
const Placeholder = lazy(() => import('./pages/Placeholder'))
|
||||
const ForbiddenPage = lazy(() => import('./pages/403'))
|
||||
const NotFoundPage = lazy(() => import('./pages/404'))
|
||||
@ -39,6 +40,7 @@ function App() {
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="assistant" element={<TaskAssistant />} />
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type React from 'react'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
import type React from 'react'
|
||||
import { RobotOutlined, DashboardOutlined,
|
||||
UserOutlined,
|
||||
BookOutlined,
|
||||
ImportOutlined,
|
||||
@ -23,6 +22,7 @@ export interface AdminMenuItem {
|
||||
|
||||
export const adminMenuItems: AdminMenuItem[] = [
|
||||
{ path: '/', name: '总览', icon: <DashboardOutlined /> },
|
||||
{ path: '/assistant', name: '任务助理', icon: <RobotOutlined /> },
|
||||
{
|
||||
path: '/users',
|
||||
name: '用户管理',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { ProLayout } from '@ant-design/pro-components'
|
||||
import { Dropdown, Avatar, Tag, Space, message } from 'antd'
|
||||
import { LogoutOutlined, UserOutlined } from '@ant-design/icons'
|
||||
@ -9,6 +9,7 @@ import type { AdminRole } from '@/types/admin'
|
||||
|
||||
const breadcrumbMap: Record<string, string> = {
|
||||
'/': '总览',
|
||||
'/assistant': '任务助理',
|
||||
'/users': '用户管理',
|
||||
'/users/admins': '管理员',
|
||||
'/users/members': '普通用户',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Row, Col, Typography } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import ReactEChartsCore from 'echarts-for-react/esm/core'
|
||||
|
||||
203
src/pages/TaskAssistant.tsx
Normal file
203
src/pages/TaskAssistant.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Typography, Input, Button, Space, Avatar, Spin, theme } from 'antd'
|
||||
import { SendOutlined, RobotOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { sendMessage } from '@/services/ai-chat'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export default function TaskAssistant() {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const { token } = theme.useToken()
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || loading) return
|
||||
|
||||
const userMsg: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const reply = await sendMessage([...messages, userMsg].map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})))
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: reply,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setMessages(prev => [...prev, assistantMsg])
|
||||
} catch (err) {
|
||||
const errorMsg: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: '抱歉,请求失败:' + (err instanceof Error ? err.message : '未知错误'),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setMessages(prev => [...prev, errorMsg])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 112px)' }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>任务助理</Title>
|
||||
<Text type="secondary">AI 助手,随时为你提供帮助</Text>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
padding: '20px 24px',
|
||||
marginBottom: 16,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: token.colorTextQuaternary,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined style={{ fontSize: 48, marginBottom: 16 }} />
|
||||
<Text type="secondary" style={{ fontSize: 16 }}>有什么我可以帮助你的?</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>
|
||||
输入你的问题,AI 助手将为你解答
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size={36}
|
||||
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
|
||||
style={{
|
||||
backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '72%',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 12,
|
||||
background: msg.role === 'user'
|
||||
? token.colorPrimary
|
||||
: token.colorFillAlter,
|
||||
color: msg.role === 'user' ? '#fff' : token.colorText,
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||
<Avatar
|
||||
size={36}
|
||||
icon={<RobotOutlined />}
|
||||
style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '72%',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 12,
|
||||
background: token.colorFillAlter,
|
||||
}}
|
||||
>
|
||||
<Spin size="small" /> <Text type="secondary">思考中...</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<TextArea
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入你的问题,Enter 发送,Shift+Enter 换行"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
disabled={loading}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
loading={loading}
|
||||
disabled={!input.trim()}
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { lazy } from 'react'
|
||||
import { lazy } from 'react'
|
||||
import type { AdminRole } from '@/types/admin'
|
||||
|
||||
const Dashboard = lazy(() => import('@/pages/Dashboard'))
|
||||
const TaskAssistant = lazy(() => import('@/pages/TaskAssistant'))
|
||||
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
||||
|
||||
export interface RouteConfig {
|
||||
@ -13,6 +14,7 @@ export interface RouteConfig {
|
||||
|
||||
export const routeConfig: RouteConfig[] = [
|
||||
{ path: '/', title: '总览', element: Dashboard },
|
||||
{ path: '/assistant', title: '任务助理', element: TaskAssistant },
|
||||
{ path: '/users', title: '用户管理', element: UserManagement, requiredRole: 'ADMIN' },
|
||||
{ path: '/users/admins', title: '管理员', element: UserManagement, requiredRole: 'SUPER_ADMIN' },
|
||||
{ path: '/users/members', title: '普通用户', element: UserManagement },
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type {
|
||||
import type {
|
||||
AdminUser,
|
||||
DashboardStats,
|
||||
AuditLog,
|
||||
@ -40,12 +40,8 @@ export function getCurrentAdmin(): Promise<AdminUser> {
|
||||
// ── Dashboard ─────────────────────────────────────────
|
||||
|
||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
try {
|
||||
return await api.get<DashboardStats>('/admin-api/dashboard/stats')
|
||||
} catch {
|
||||
if (import.meta.env.DEV) return MOCK_DASHBOARD_STATS
|
||||
throw new Error('获取仪表盘数据失败')
|
||||
}
|
||||
return api.get<DashboardStats>('/admin-api/dashboard/stats')
|
||||
}
|
||||
|
||||
// ── Admin Users ───────────────────────────────────────
|
||||
|
||||
16
src/services/ai-chat.ts
Normal file
16
src/services/ai-chat.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { api } from './http-client'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ChatResponse {
|
||||
content: string
|
||||
usage?: { model?: string; inputTokens?: number; outputTokens?: number }
|
||||
}
|
||||
|
||||
export async function sendMessage(messages: ChatMessage[]): Promise<string> {
|
||||
const data = await api.post<ChatResponse>('/admin-api/ai/chat', { messages })
|
||||
return data.content || '(无回复内容)'
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
@ -12,6 +12,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': 'https://api.longde.cloud',
|
||||
'/admin-api': 'https://api.longde.cloud',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user