feat: add TaskAssistant page with AI chat + admin layout updates + service layer

This commit is contained in:
WangDL 2026-05-22 00:38:56 +08:00
parent 4dad572731
commit f552ba0619
10 changed files with 238 additions and 17 deletions

2
package-lock.json generated
View File

@ -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": {

View File

@ -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={

View File

@ -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: '用户管理',

View File

@ -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': '普通用户',

View File

@ -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
View 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>
)
}

View File

@ -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 },

View File

@ -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('获取仪表盘数据失败')
}
if (import.meta.env.DEV) return MOCK_DASHBOARD_STATS
return api.get<DashboardStats>('/admin-api/dashboard/stats')
}
// ── Admin Users ───────────────────────────────────────

16
src/services/ai-chat.ts Normal file
View 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 || '(无回复内容)'
}

View File

@ -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',