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": {
|
"node_modules/echarts": {
|
||||||
"version": "6.1.0",
|
"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==",
|
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Suspense, lazy } from 'react'
|
import { Suspense, lazy } from 'react'
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { ConfigProvider } from 'antd'
|
import { ConfigProvider } from 'antd'
|
||||||
@ -12,6 +12,7 @@ import AdminLayout from './layouts/AdminLayout'
|
|||||||
const Login = lazy(() => import('./pages/Login'))
|
const Login = lazy(() => import('./pages/Login'))
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||||
const UserManagement = lazy(() => import('./pages/UserManagement'))
|
const UserManagement = lazy(() => import('./pages/UserManagement'))
|
||||||
|
const TaskAssistant = lazy(() => import('./pages/TaskAssistant'))
|
||||||
const Placeholder = lazy(() => import('./pages/Placeholder'))
|
const Placeholder = lazy(() => import('./pages/Placeholder'))
|
||||||
const ForbiddenPage = lazy(() => import('./pages/403'))
|
const ForbiddenPage = lazy(() => import('./pages/403'))
|
||||||
const NotFoundPage = lazy(() => import('./pages/404'))
|
const NotFoundPage = lazy(() => import('./pages/404'))
|
||||||
@ -39,6 +40,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="assistant" element={<TaskAssistant />} />
|
||||||
<Route
|
<Route
|
||||||
path="users"
|
path="users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import {
|
import { RobotOutlined, DashboardOutlined,
|
||||||
DashboardOutlined,
|
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
@ -23,6 +22,7 @@ export interface AdminMenuItem {
|
|||||||
|
|
||||||
export const adminMenuItems: AdminMenuItem[] = [
|
export const adminMenuItems: AdminMenuItem[] = [
|
||||||
{ path: '/', name: '总览', icon: <DashboardOutlined /> },
|
{ path: '/', name: '总览', icon: <DashboardOutlined /> },
|
||||||
|
{ path: '/assistant', name: '任务助理', icon: <RobotOutlined /> },
|
||||||
{
|
{
|
||||||
path: '/users',
|
path: '/users',
|
||||||
name: '用户管理',
|
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 { ProLayout } from '@ant-design/pro-components'
|
||||||
import { Dropdown, Avatar, Tag, Space, message } from 'antd'
|
import { Dropdown, Avatar, Tag, Space, message } from 'antd'
|
||||||
import { LogoutOutlined, UserOutlined } from '@ant-design/icons'
|
import { LogoutOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
@ -9,6 +9,7 @@ import type { AdminRole } from '@/types/admin'
|
|||||||
|
|
||||||
const breadcrumbMap: Record<string, string> = {
|
const breadcrumbMap: Record<string, string> = {
|
||||||
'/': '总览',
|
'/': '总览',
|
||||||
|
'/assistant': '任务助理',
|
||||||
'/users': '用户管理',
|
'/users': '用户管理',
|
||||||
'/users/admins': '管理员',
|
'/users/admins': '管理员',
|
||||||
'/users/members': '普通用户',
|
'/users/members': '普通用户',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Row, Col, Typography } from 'antd'
|
import { Row, Col, Typography } from 'antd'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import ReactEChartsCore from 'echarts-for-react/esm/core'
|
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'
|
import type { AdminRole } from '@/types/admin'
|
||||||
|
|
||||||
const Dashboard = lazy(() => import('@/pages/Dashboard'))
|
const Dashboard = lazy(() => import('@/pages/Dashboard'))
|
||||||
|
const TaskAssistant = lazy(() => import('@/pages/TaskAssistant'))
|
||||||
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
||||||
|
|
||||||
export interface RouteConfig {
|
export interface RouteConfig {
|
||||||
@ -13,6 +14,7 @@ export interface RouteConfig {
|
|||||||
|
|
||||||
export const routeConfig: RouteConfig[] = [
|
export const routeConfig: RouteConfig[] = [
|
||||||
{ path: '/', title: '总览', element: Dashboard },
|
{ path: '/', title: '总览', element: Dashboard },
|
||||||
|
{ path: '/assistant', title: '任务助理', element: TaskAssistant },
|
||||||
{ path: '/users', title: '用户管理', element: UserManagement, requiredRole: 'ADMIN' },
|
{ path: '/users', title: '用户管理', element: UserManagement, requiredRole: 'ADMIN' },
|
||||||
{ path: '/users/admins', title: '管理员', element: UserManagement, requiredRole: 'SUPER_ADMIN' },
|
{ path: '/users/admins', title: '管理员', element: UserManagement, requiredRole: 'SUPER_ADMIN' },
|
||||||
{ path: '/users/members', title: '普通用户', element: UserManagement },
|
{ path: '/users/members', title: '普通用户', element: UserManagement },
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminUser,
|
AdminUser,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
@ -40,12 +40,8 @@ export function getCurrentAdmin(): Promise<AdminUser> {
|
|||||||
// ── Dashboard ─────────────────────────────────────────
|
// ── Dashboard ─────────────────────────────────────────
|
||||||
|
|
||||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
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
|
if (import.meta.env.DEV) return MOCK_DASHBOARD_STATS
|
||||||
throw new Error('获取仪表盘数据失败')
|
return api.get<DashboardStats>('/admin-api/dashboard/stats')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin Users ───────────────────────────────────────
|
// ── 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 react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
@ -12,6 +12,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
|
host: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'https://api.longde.cloud',
|
'/api': 'https://api.longde.cloud',
|
||||||
'/admin-api': 'https://api.longde.cloud',
|
'/admin-api': 'https://api.longde.cloud',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user