init: 2026Technology-Competition initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { useRoutes } from 'react-router-dom'
|
||||
import routes from './routes'
|
||||
|
||||
function App() {
|
||||
const element = useRoutes(routes)
|
||||
return <>{element}</>
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,17 @@
|
||||
import { App } from 'antd'
|
||||
|
||||
type Static = ReturnType<typeof App.useApp>
|
||||
|
||||
let message: Static['message']
|
||||
let notification: Static['notification']
|
||||
let modal: Static['modal']
|
||||
|
||||
export default function AntdStatic() {
|
||||
const staticFn = App.useApp()
|
||||
message = staticFn.message
|
||||
notification = staticFn.notification
|
||||
modal = staticFn.modal
|
||||
return null
|
||||
}
|
||||
|
||||
export { message, notification, modal }
|
||||
@@ -0,0 +1,327 @@
|
||||
import request from '../request'
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
id: number
|
||||
userid: string
|
||||
username: string
|
||||
email?: string
|
||||
departmentId?: number
|
||||
departmentName?: string
|
||||
isActive?: boolean
|
||||
agentAutoExecute?: boolean
|
||||
roleIds?: number[]
|
||||
roles?: string[]
|
||||
lastLoginAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface UserPayload {
|
||||
userid?: string
|
||||
username: string
|
||||
email?: string
|
||||
password?: string
|
||||
departmentId?: number
|
||||
roleIds?: number[]
|
||||
isActive?: boolean
|
||||
agentAutoExecute?: boolean
|
||||
}
|
||||
|
||||
export interface DeptNode {
|
||||
id: number
|
||||
name: string
|
||||
parentId?: number
|
||||
sortOrder?: number
|
||||
children?: DeptNode[]
|
||||
}
|
||||
|
||||
export interface PermissionItem {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
resource: string
|
||||
}
|
||||
|
||||
export interface RoleItem {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
dataScope?: string
|
||||
agentAutoExecute?: boolean
|
||||
permissionIds?: number[]
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface RolePayload {
|
||||
name: string
|
||||
description?: string
|
||||
dataScope?: string
|
||||
agentAutoExecute?: boolean
|
||||
permissionIds?: number[]
|
||||
}
|
||||
|
||||
export interface LogItem {
|
||||
id: number
|
||||
operator: string
|
||||
action: string
|
||||
resource: string
|
||||
detail: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface LogQuery {
|
||||
operator?: string
|
||||
actionType?: string
|
||||
keyword?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
list: (params: Record<string, unknown>) => request.get('/users', { params }),
|
||||
create: (data: UserPayload) => request.post('/users', data),
|
||||
update: (id: number, data: UserPayload) => request.put(`/users/${id}`, data),
|
||||
updateStatus: (id: number, isActive: boolean) =>
|
||||
request.put(`/users/${id}/status`, null, { params: { isActive } }),
|
||||
exportCsv: (params: Record<string, unknown>) =>
|
||||
request.get('/users/export', { params, responseType: 'blob' }),
|
||||
}
|
||||
|
||||
export const deptApi = {
|
||||
tree: () => request.get('/departments'),
|
||||
}
|
||||
|
||||
export const roleApi = {
|
||||
list: () => request.get('/roles'),
|
||||
permissions: () => request.get('/roles/permissions'),
|
||||
create: (data: RolePayload) => request.post('/roles', data),
|
||||
update: (id: number, data: RolePayload) => request.put(`/roles/${id}`, data),
|
||||
}
|
||||
|
||||
export const logApi = {
|
||||
list: (params: LogQuery) => request.get('/logs', { params }),
|
||||
}
|
||||
|
||||
export interface ImportRow {
|
||||
rowNo?: number
|
||||
title?: string
|
||||
docType?: string
|
||||
phase?: string
|
||||
priority?: string
|
||||
deadline?: string
|
||||
reviewDate?: string
|
||||
subProject?: string
|
||||
category?: string
|
||||
impactLevel?: string
|
||||
description?: string
|
||||
assigneeUserid?: string
|
||||
departmentName?: string
|
||||
status?: string
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface ImportPreview {
|
||||
total: number
|
||||
validCount: number
|
||||
errorCount: number
|
||||
headerValid?: boolean
|
||||
rows: ImportRow[]
|
||||
}
|
||||
|
||||
export interface ImportRecord {
|
||||
id: number
|
||||
fileName: string
|
||||
totalCount: number
|
||||
successCount: number
|
||||
failCount: number
|
||||
status: string
|
||||
errorLog?: string
|
||||
operator?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface ImportSuggestion {
|
||||
rowNo?: number
|
||||
field?: string
|
||||
fieldName?: string
|
||||
original?: string
|
||||
suggested?: string
|
||||
reason?: string
|
||||
level?: string
|
||||
}
|
||||
|
||||
export interface AgentValidateResponse {
|
||||
usable: boolean
|
||||
usableReason?: string
|
||||
suggestions: ImportSuggestion[]
|
||||
engine: string
|
||||
}
|
||||
|
||||
export const importApi = {
|
||||
template: () =>
|
||||
request.get('/import/template', { responseType: 'blob' }),
|
||||
preview: (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post('/import/excel', form)
|
||||
},
|
||||
aiValidate: (rows: ImportRow[]) =>
|
||||
request.post('/import/ai-validate', rows, { timeout: 1800000 }),
|
||||
confirm: (fileName: string, rows: ImportRow[]) =>
|
||||
request.post('/import/confirm', { fileName, rows }),
|
||||
records: (page = 1, pageSize = 20) =>
|
||||
request.get('/import/records', { params: { page, pageSize } }),
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
maxSteps?: number
|
||||
autoExecuteHighRisk?: boolean
|
||||
userRateLimit?: number
|
||||
}
|
||||
|
||||
export const agentApi = {
|
||||
getConfig: () => request.get('/agent/config'),
|
||||
updateConfig: (config: AgentConfig) => request.put('/agent/config', config),
|
||||
}
|
||||
|
||||
export interface PromptTemplateItem {
|
||||
id: number
|
||||
templateId: string
|
||||
name: string
|
||||
category: string
|
||||
version: number
|
||||
content: string
|
||||
variables?: string
|
||||
outputSchema?: string
|
||||
isActive?: boolean
|
||||
isDefault?: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface PromptVersion {
|
||||
id: number
|
||||
templateId: string
|
||||
version: number
|
||||
content: string
|
||||
changeLog?: string
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface PromptLogItem {
|
||||
id: number
|
||||
requestId: string
|
||||
templateId: string
|
||||
templateVersion: number
|
||||
renderedPrompt: string
|
||||
executionTimeMs?: number
|
||||
llmModel?: string
|
||||
modelProvider?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface PromptStats {
|
||||
totalTemplates: number
|
||||
activeTemplates: number
|
||||
totalVersions: number
|
||||
totalRenders: number
|
||||
}
|
||||
|
||||
export const promptApi = {
|
||||
list: (params: Record<string, unknown>) => request.get('/prompts', { params }),
|
||||
detail: (templateId: string) => request.get(`/prompts/${templateId}`),
|
||||
create: (data: Record<string, unknown>) => request.post('/prompts', data),
|
||||
update: (templateId: string, data: Record<string, unknown>) =>
|
||||
request.put(`/prompts/${templateId}`, data),
|
||||
rollback: (templateId: string, version: number) =>
|
||||
request.post(`/prompts/${templateId}/rollback`, null, { params: { version } }),
|
||||
test: (templateId: string, variables: Record<string, unknown>) =>
|
||||
request.post(`/prompts/${templateId}/test`, { variables }),
|
||||
versions: (templateId: string) => request.get(`/prompts/${templateId}/versions`),
|
||||
logs: (page = 1, pageSize = 20) =>
|
||||
request.get('/prompts/logs', { params: { page, pageSize } }),
|
||||
stats: () => request.get('/prompts/stats'),
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
provider: string
|
||||
ollamaBaseUrl?: string
|
||||
ollamaChatModel?: string
|
||||
ollamaEmbeddingModel?: string
|
||||
deepseekModel?: string
|
||||
deepseekEmbeddingModel?: string
|
||||
deepseekApiKey?: string
|
||||
autoFallbackEnabled?: boolean
|
||||
}
|
||||
|
||||
export const aiConfigApi = {
|
||||
get: () => request.get('/ai/config'),
|
||||
update: (config: AiConfig) => request.put('/ai/config', config),
|
||||
test: () => request.post('/ai/config/test'),
|
||||
}
|
||||
|
||||
export interface AgentOverview {
|
||||
todayExecutions: number
|
||||
yesterdayExecutions: number
|
||||
growthRate: number
|
||||
todayPlans: number
|
||||
toolTotalCount: number
|
||||
toolSuccessCount: number
|
||||
toolSuccessRate: number
|
||||
pendingApprovals: number
|
||||
latestExecutions: AgentExecution[]
|
||||
trendData: { hour: string; count: number }[]
|
||||
}
|
||||
|
||||
export interface AgentExecution {
|
||||
id: number
|
||||
planId: number
|
||||
toolName: string
|
||||
status: string
|
||||
executionTimeMs?: number
|
||||
outputResult?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface AgentPlanItem {
|
||||
planId: number
|
||||
issueId: number
|
||||
issueNo: string
|
||||
issueTitle: string
|
||||
goal: string
|
||||
status: string
|
||||
requiresApproval: boolean
|
||||
approvalStatus: string
|
||||
approvalComment?: string
|
||||
toolName?: string
|
||||
toolParams?: string
|
||||
approvalReason?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface AgentToolItem {
|
||||
name: string
|
||||
description: string
|
||||
isWrite: boolean
|
||||
}
|
||||
|
||||
export const agentOverviewApi = {
|
||||
getOverview: () => request.get('/agent/overview'),
|
||||
getPlans: (params: { approvalStatus?: string; page?: number; pageSize?: number }) =>
|
||||
request.get('/agent/plans', { params }),
|
||||
approvePlan: (planId: number, comment?: string) =>
|
||||
request.post(`/agent/approval/${planId}/approve`, { comment }),
|
||||
rejectPlan: (planId: number, comment?: string) =>
|
||||
request.post(`/agent/approval/${planId}/reject`, { comment }),
|
||||
getTools: () => request.get('/agent/tools'),
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
open: '待处理',
|
||||
in_progress: '进行中',
|
||||
resolved: '已解决',
|
||||
verified: '已验证',
|
||||
closed: '已关闭',
|
||||
rejected: '已驳回'
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'default',
|
||||
open: 'orange',
|
||||
in_progress: 'blue',
|
||||
resolved: 'cyan',
|
||||
verified: 'purple',
|
||||
closed: 'green',
|
||||
rejected: 'red'
|
||||
}
|
||||
|
||||
export const ACTION_LABELS: Record<string, string> = {
|
||||
CREATE: '创建指摘', UPDATE: '更新指摘', DELETE: '删除指摘',
|
||||
STATUS_CHANGE: '状态流转', BATCH_ASSIGN: '批量分配', BATCH_NOTIFY: '批量催办'
|
||||
}
|
||||
|
||||
export const PRIORITY_LABELS: Record<string, string> = {
|
||||
urgent: '紧急',
|
||||
high: '高',
|
||||
medium: '中',
|
||||
low: '低'
|
||||
}
|
||||
|
||||
export const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: 'red',
|
||||
high: 'orange',
|
||||
medium: 'default',
|
||||
low: 'gray'
|
||||
}
|
||||
|
||||
export const PHASE_OPTIONS = ['需求', '设计', '编码', '测试', '部署', '运维']
|
||||
|
||||
export const SUB_PROJECT_OPTIONS = ['用户管理子系统', '权限控制子系统', '数据报表子系统']
|
||||
|
||||
export const CATEGORY_OPTIONS = ['功能缺陷', 'UI/UX问题', '性能问题', '安全漏洞', '文档错误']
|
||||
|
||||
export const IMPACT_LEVEL_OPTIONS = ['高', '中', '低']
|
||||
|
||||
export const USERS = [
|
||||
{ id: 1, name: '系统管理员' },
|
||||
{ id: 2, name: '张三' },
|
||||
{ id: 3, name: '李四' }
|
||||
]
|
||||
|
||||
export const DEPARTMENTS = [
|
||||
{ id: 1, name: '总公司' },
|
||||
{ id: 2, name: '技术部' },
|
||||
{ id: 3, name: '质量部' },
|
||||
{ id: 4, name: '产品部' }
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Layout, Menu, Button, Avatar, Dropdown, Badge, Space, theme, Empty } from 'antd'
|
||||
import { message } from '../antdStatic'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
BugOutlined,
|
||||
UploadOutlined,
|
||||
RobotOutlined,
|
||||
BookOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
FileTextOutlined,
|
||||
NotificationOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { logout } from '../store/slices/authSlice'
|
||||
import type { RootState } from '../store'
|
||||
import { listNotifications, getUnreadCount, markNotificationRead, markAllNotificationsRead } from '../services/notification'
|
||||
import type { NotificationItem } from '../services/notification'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '工作台' },
|
||||
{
|
||||
key: 'issue',
|
||||
icon: <BugOutlined />,
|
||||
label: '指摘管理',
|
||||
children: [
|
||||
{ key: '/issues', icon: <FileTextOutlined />, label: '指摘列表' },
|
||||
{ key: '/issues/new', icon: <FileTextOutlined />, label: '新建指摘' },
|
||||
{ key: '/batch-input', icon: <UploadOutlined />, label: '批量录入' }
|
||||
]
|
||||
},
|
||||
{ key: '/ai-analysis', icon: <RobotOutlined />, label: 'AI智能分析' },
|
||||
{ key: '/knowledge-base', icon: <BookOutlined />, label: '知识库管理' },
|
||||
{
|
||||
key: 'system',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统管理',
|
||||
children: [
|
||||
{ key: '/system/users', icon: <TeamOutlined />, label: '用户管理' },
|
||||
{ key: '/system/roles', icon: <UserOutlined />, label: '角色权限' },
|
||||
{ key: '/system/logs', icon: <FileTextOutlined />, label: '系统日志' },
|
||||
{ key: '/system/agent-admin', icon: <RobotOutlined />, label: 'Agent管理' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export default function MainLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dispatch = useDispatch()
|
||||
const username = useSelector((state: RootState) => state.auth.username)
|
||||
const { token: { colorBgContainer, borderRadiusLG } } = theme.useToken()
|
||||
|
||||
const [unread, setUnread] = useState(0)
|
||||
const [notifs, setNotifs] = useState<NotificationItem[]>([])
|
||||
|
||||
const loadUnread = async () => {
|
||||
try {
|
||||
const res: any = await getUnreadCount()
|
||||
setUnread(res.data?.count || 0)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const loadNotifs = async () => {
|
||||
try {
|
||||
const res: any = await listNotifications(1, 20)
|
||||
setNotifs(res.data?.items || [])
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUnread()
|
||||
const timer = setInterval(loadUnread, 60000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const handleNotifOpen = (open: boolean) => {
|
||||
if (open) loadNotifs()
|
||||
}
|
||||
|
||||
const handleNotifClick = async (item: NotificationItem) => {
|
||||
try {
|
||||
await markNotificationRead(item.id)
|
||||
loadUnread()
|
||||
} catch { /* ignore */ }
|
||||
if (item.link?.startsWith('/issues')) navigate(item.link)
|
||||
}
|
||||
|
||||
const handleReadAll = async () => {
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
message.success('已全部标记为已读')
|
||||
loadNotifs()
|
||||
loadUnread()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const notifMenuItems = [
|
||||
{
|
||||
key: 'header',
|
||||
label: notifs.length ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 8px' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>通知</span>
|
||||
<Button type="link" size="small" disabled={!notifs.some(n => !n.isRead)} onClick={handleReadAll}>全部已读</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无通知" imageStyle={{ height: 40 }} />
|
||||
),
|
||||
disabled: !notifs.length
|
||||
},
|
||||
...notifs.slice(0, 20).map((n) => ({
|
||||
key: `n-${n.id}`,
|
||||
label: (
|
||||
<div onClick={() => handleNotifClick(n)} style={{ width: 260, padding: '4px 0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: n.isRead ? 400 : 600, fontSize: 13 }}>{n.title}</span>
|
||||
{!n.isRead && <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ff4d4f' }} />}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginTop: 2, whiteSpace: 'pre-wrap' }}>{n.content}</div>
|
||||
<div style={{ fontSize: 11, color: '#bbb', marginTop: 2 }}>{n.createdAt?.replace('T', ' ').slice(0, 16)}</div>
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
]
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logout())
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const userMenuItems = [
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout }
|
||||
]
|
||||
|
||||
const openKeys = location.pathname.startsWith('/system') ? ['system']
|
||||
: location.pathname.startsWith('/issue') || location.pathname.startsWith('/issues') || location.pathname.startsWith('/batch-input') ? ['issue']
|
||||
: []
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed}>
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: collapsed ? 16 : 20, fontWeight: 'bold' }}>
|
||||
{collapsed ? 'IMS' : '指摘管理系统'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
defaultOpenKeys={openKeys}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ padding: '0 24px', background: colorBgContainer, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Button type="text" icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} onClick={() => setCollapsed(!collapsed)} />
|
||||
<Space>
|
||||
<Dropdown trigger={['click']} onOpenChange={handleNotifOpen} menu={{ items: notifMenuItems, style: { maxHeight: 480, overflowY: 'auto' } }}>
|
||||
<Badge count={unread} size="small" style={{ cursor: 'pointer' }}>
|
||||
<NotificationOutlined style={{ fontSize: 18 }} />
|
||||
</Badge>
|
||||
</Dropdown>
|
||||
<Dropdown trigger={['click']} menu={{ items: userMenuItems }}>
|
||||
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
<span>{username || '系统管理员'}</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ margin: 24 }}>
|
||||
<div style={{ padding: 24, minHeight: 360, background: colorBgContainer, borderRadius: borderRadiusLG }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { App as AntdApp, ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import App from './App'
|
||||
import AntdStatic from './antdStatic'
|
||||
import { store } from './store'
|
||||
import theme from './theme'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider locale={zhCN} theme={theme}>
|
||||
<AntdApp>
|
||||
<AntdStatic />
|
||||
<App />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,508 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Typography, Table, Button, Modal, Form, Select, Input, InputNumber, DatePicker,
|
||||
Tag, Space, Tooltip, Row, Col, Card, Tabs
|
||||
} from 'antd'
|
||||
import { message } from '../../antdStatic'
|
||||
import {
|
||||
ThunderboltOutlined, DownloadOutlined, SearchOutlined, ReloadOutlined,
|
||||
EyeOutlined, RedoOutlined
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
aiAnalysisApi, departmentApi, issuesApi,
|
||||
AiAnalysisRecord, RecordQuery, DepartmentOption, IssueListItem,
|
||||
ISSUE_STATUS_OPTIONS, ISSUE_PHASE_OPTIONS, ANALYSIS_STATUS_OPTIONS, ISSUE_PRIORITY_OPTIONS
|
||||
} from './services'
|
||||
import AiOverviewTab from './overview'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const pageSize = 20
|
||||
|
||||
type TreeRecord = AiAnalysisRecord & { children?: TreeRecord[] }
|
||||
|
||||
const buildTree = (items: AiAnalysisRecord[]): TreeRecord[] => {
|
||||
const groups = new Map<number, AiAnalysisRecord[]>()
|
||||
for (const r of items) {
|
||||
const arr = groups.get(r.issueId) || []
|
||||
arr.push(r)
|
||||
groups.set(r.issueId, arr)
|
||||
}
|
||||
const tree: TreeRecord[] = []
|
||||
for (const [, arr] of groups) {
|
||||
arr.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
|
||||
const [head, ...rest] = arr
|
||||
tree.push({ ...head, children: rest })
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
export default function AiAnalysisPage() {
|
||||
const [records, setRecords] = useState<AiAnalysisRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [departments, setDepartments] = useState<DepartmentOption[]>([])
|
||||
const [query, setQuery] = useState<RecordQuery>({ page: 1, pageSize })
|
||||
const [filterForm] = Form.useForm()
|
||||
|
||||
const [detail, setDetail] = useState<AiAnalysisRecord | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const [generateOpen, setGenerateOpen] = useState(false)
|
||||
const pollTimer = useRef<number | null>(null)
|
||||
|
||||
const schedulePoll = (items: AiAnalysisRecord[], q: RecordQuery) => {
|
||||
if (pollTimer.current) window.clearTimeout(pollTimer.current)
|
||||
const hasRunning = items.some(r => r.status === 'processing' || r.status === 'pending')
|
||||
if (hasRunning) {
|
||||
pollTimer.current = window.setTimeout(() => loadRecords(q), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecords = async (q: RecordQuery) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await aiAnalysisApi.records(q)
|
||||
const items = res.data?.items || []
|
||||
setRecords(items)
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(q.page)
|
||||
schedulePoll(items, q)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords(query)
|
||||
return () => { if (pollTimer.current) window.clearTimeout(pollTimer.current) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
departmentApi.list().then((res: any) => {
|
||||
setDepartments(res.data || [])
|
||||
}).catch(() => { /* ignore */ })
|
||||
}, [])
|
||||
|
||||
const buildQuery = (): RecordQuery => {
|
||||
const values = filterForm.getFieldsValue()
|
||||
return {
|
||||
page: 1,
|
||||
pageSize,
|
||||
id: values.id,
|
||||
issueId: values.issueId,
|
||||
departmentId: values.departmentId,
|
||||
status: values.status,
|
||||
startDate: values.dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: values.dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const params = buildQuery()
|
||||
setQuery(params)
|
||||
loadRecords(params)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
filterForm.resetFields()
|
||||
const params: RecordQuery = { page: 1, pageSize }
|
||||
setQuery(params)
|
||||
loadRecords(params)
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob: any = await aiAnalysisApi.exportRecords(query)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'ai-analysis.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
message.success('导出成功')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleReanalyze = async (record: AiAnalysisRecord) => {
|
||||
if (!record.issueId) return
|
||||
try {
|
||||
const res: any = await aiAnalysisApi.batchGenerate({ issueIds: [record.issueId] })
|
||||
message.success(`已重新提交分析 (${res.data?.submitted ?? 0})`)
|
||||
setTimeout(() => loadRecords(query), 1500)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleFeedback = async (id: number, isHelpful: boolean) => {
|
||||
try {
|
||||
await aiAnalysisApi.feedback(id, isHelpful)
|
||||
message.success('已记录反馈')
|
||||
loadRecords(query)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
pending: 'default', processing: 'processing', completed: 'green', failed: 'red'
|
||||
}
|
||||
|
||||
const treeData = useMemo<TreeRecord[]>(() => records.map(r => ({ ...r })), [records])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '指摘ID', dataIndex: 'issueId', key: 'issueId', width: 90,
|
||||
sorter: (a: AiAnalysisRecord, b: AiAnalysisRecord) => a.issueId - b.issueId,
|
||||
render: (v: number) => <span className="font-mono text-gray-500">{v}</span>
|
||||
},
|
||||
{
|
||||
title: 'ID', key: 'id', width: 150,
|
||||
sorter: (a: AiAnalysisRecord, b: AiAnalysisRecord) => (a.issueNo || '').localeCompare(b.issueNo || ''),
|
||||
render: (_: any, r: AiAnalysisRecord) => (
|
||||
<Space size={4} wrap>
|
||||
<span className="font-mono font-bold text-blue-600">{r.issueNo}</span>
|
||||
<Tag color={statusColor[r.status] || 'default'} style={{ fontSize: 10 }}>{r.status}</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '归属部门', dataIndex: 'departmentName', key: 'departmentName', width: 120,
|
||||
sorter: (a: AiAnalysisRecord, b: AiAnalysisRecord) => (a.departmentName || '').localeCompare(b.departmentName || ''),
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{ title: '提取关键词', dataIndex: 'keywords', key: 'keywords', width: 150, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '问题分类', dataIndex: 'category', key: 'category', width: 120, render: (v: string) => v || '-' },
|
||||
{ title: '根因分析', dataIndex: 'rootCause', key: 'rootCause', width: 220, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: 'AI 整改建议', dataIndex: 'suggestion', key: 'suggestion', width: 300,
|
||||
render: (v: string) => <span style={{ whiteSpace: 'pre-wrap' }}>{v || '-'}</span>
|
||||
},
|
||||
{
|
||||
title: '时间', key: 'time', width: 140, defaultSortOrder: 'descend' as const,
|
||||
sorter: (a: AiAnalysisRecord, b: AiAnalysisRecord) => (a.completedAt || a.startedAt || '').localeCompare(b.completedAt || b.startedAt || ''),
|
||||
render: (_: any, r: AiAnalysisRecord) => {
|
||||
if (r.status === 'processing' || r.status === 'pending') {
|
||||
if (!r.startedAt) return '-'
|
||||
const mins = Math.max(1, Math.floor(dayjs().diff(dayjs(r.startedAt), 'minute')))
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span>开始 {dayjs(r.startedAt).format('MM-DD HH:mm:ss')}</span>
|
||||
<span style={{ color: '#faad14', fontSize: 12 }}>已运行 {mins} 分钟</span>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
return r.completedAt ? `完成 ${dayjs(r.completedAt).format('MM-DD HH:mm:ss')}` : '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '反馈', dataIndex: 'helpfulCount', key: 'feedback', width: 100,
|
||||
sorter: (a: AiAnalysisRecord, b: AiAnalysisRecord) => (a.helpfulCount || 0) - (b.helpfulCount || 0),
|
||||
render: (v: number, r: AiAnalysisRecord) => (
|
||||
v > 0
|
||||
? <span className="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs font-bold">有帮助</span>
|
||||
: <Tooltip title="标记为有帮助"><Button size="small" type="text" onClick={() => handleFeedback(r.id, true)}>有帮助</Button></Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 90, align: 'right' as const, fixed: 'right' as const,
|
||||
render: (_: any, r: AiAnalysisRecord) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="查看详情">
|
||||
<Button type="text" icon={<EyeOutlined />} onClick={() => { setDetail(r); setDetailOpen(true) }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重新分析">
|
||||
<Button type="text" icon={<RedoOutlined />} onClick={() => handleReanalyze(r)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultActiveKey="records" items={[
|
||||
{
|
||||
key: 'overview',
|
||||
label: '分析总览',
|
||||
children: <AiOverviewTab />
|
||||
},
|
||||
{
|
||||
key: 'records',
|
||||
label: '分析记录',
|
||||
children: (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>AI 分析记录</Typography.Title>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Form form={filterForm}>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col flex="1">
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 700, textTransform: 'uppercase', marginBottom: 6 }}>ID</div>
|
||||
<Form.Item name="id" noStyle>
|
||||
<InputNumber placeholder="输入 ID(数字)" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="1">
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 700, textTransform: 'uppercase', marginBottom: 6 }}>指摘ID</div>
|
||||
<Form.Item name="issueId" noStyle>
|
||||
<InputNumber placeholder="输入指摘ID(数字)" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="1">
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 700, textTransform: 'uppercase', marginBottom: 6 }}>归属部门</div>
|
||||
<Form.Item name="departmentId" noStyle>
|
||||
<Select
|
||||
placeholder="全部"
|
||||
allowClear
|
||||
options={departments.map(d => ({ label: d.name, value: d.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="1">
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 700, textTransform: 'uppercase', marginBottom: 6 }}>日期范围</div>
|
||||
<Form.Item name="dateRange" noStyle>
|
||||
<RangePicker format="YYYY/MM/DD" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="1">
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 700, textTransform: 'uppercase', marginBottom: 6 }}>分析状态</div>
|
||||
<Form.Item name="status" noStyle>
|
||||
<Select placeholder="全部" allowClear options={ANALYSIS_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
|
||||
<Space>
|
||||
<Button type="primary" style={{ background: '#1f2937', borderColor: '#1f2937' }} icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => setGenerateOpen(true)}>
|
||||
生成 AI 分析
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出记录</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card style={{ borderRadius: 16, padding: 0 }} styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
dataSource={treeData}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1480 }}
|
||||
pagination={{
|
||||
current: page, total, pageSize,
|
||||
onChange: (p) => loadRecords({ ...query, page: p }),
|
||||
showTotal: t => `共 ${t} 条记录`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
title={detail ? `${detail.issueNo} · 分析详情` : '分析详情'}
|
||||
open={detailOpen}
|
||||
onCancel={() => setDetailOpen(false)}
|
||||
footer={<Button onClick={() => setDetailOpen(false)}>关闭</Button>}
|
||||
width={640}
|
||||
>
|
||||
{detail && (
|
||||
<div style={{ lineHeight: 1.8 }}>
|
||||
<div><b>指摘标题:</b>{detail.issueTitle}</div>
|
||||
<div><b>问题分类:</b>{detail.category || '-'}</div>
|
||||
<div><b>预提取关键词:</b>{detail.extractedKeywords || '-'}</div>
|
||||
<div><b>提取关键词:</b>{detail.keywords || '-'}</div>
|
||||
<div style={{ marginTop: 8 }}><b>根因分析:</b></div>
|
||||
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>{detail.rootCause || '-'}</div>
|
||||
<div style={{ marginTop: 8 }}><b>AI 整改建议:</b></div>
|
||||
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>{detail.suggestion || '-'}</div>
|
||||
{detail.status === 'failed' && detail.errorMessage && (
|
||||
<div style={{ marginTop: 12, background: '#fff1f0', border: '1px solid #ffccc7', borderRadius: 8, padding: 12, fontSize: 12, color: '#cf1322' }}>
|
||||
<b>失败原因:</b>{detail.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
|
||||
模型:{detail.modelProvider}/{detail.modelName} · 时间:{detail.createdAt}
|
||||
{detail.promptTemplateId && ` · Prompt: ${detail.promptTemplateId} v${detail.promptVersion}`}
|
||||
</div>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button size="small" onClick={() => handleFeedback(detail.id, true)}>有帮助</Button>
|
||||
<Button size="small" danger onClick={() => handleFeedback(detail.id, false)}>不准确</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<GenerateModal
|
||||
open={generateOpen}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
departments={departments}
|
||||
onGenerated={() => setTimeout(() => loadRecords(query), 1500)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GenerateModal(props: {
|
||||
open: boolean
|
||||
onCancel: () => void
|
||||
departments: DepartmentOption[]
|
||||
onGenerated: () => void
|
||||
}) {
|
||||
const [issues, setIssues] = useState<IssueListItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<IssueListItem[]>([])
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [issueForm] = Form.useForm()
|
||||
|
||||
const loadIssues = async (q: { page: number; pageSize: number }) => {
|
||||
setLoading(true)
|
||||
const values = issueForm.getFieldsValue()
|
||||
try {
|
||||
const res: any = await issuesApi.list({
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
status: values.status,
|
||||
phase: values.phase,
|
||||
departmentId: values.departmentId,
|
||||
keyword: values.keyword,
|
||||
startDate: values.dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: values.dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
})
|
||||
setIssues(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(q.page)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
setSelected([])
|
||||
issueForm.resetFields()
|
||||
loadIssues({ page: 1, pageSize })
|
||||
}
|
||||
}, [props.open])
|
||||
|
||||
const generate = async () => {
|
||||
if (selected.length === 0) {
|
||||
message.warning('请至少选择一条指摘进行 AI 分析')
|
||||
return
|
||||
}
|
||||
setGenerating(true)
|
||||
try {
|
||||
const res: any = await aiAnalysisApi.batchGenerate({ issueIds: selected.map(s => s.id) })
|
||||
const count = res.data?.submitted ?? selected.length
|
||||
const estimated = Math.ceil(count / 4) * 60
|
||||
message.success(`✅ 已对 ${count} 条指摘启动 AI 分析,预计约 ${estimated} 秒内完成`)
|
||||
props.onGenerated()
|
||||
props.onCancel()
|
||||
} catch { /* ignore */ }
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
draft: 'default', open: 'blue', in_progress: 'processing', resolved: 'green',
|
||||
verified: 'cyan', closed: 'gray', rejected: 'red'
|
||||
}
|
||||
const priorityColor: Record<string, string> = {
|
||||
high: 'red', medium: 'orange', low: 'default'
|
||||
}
|
||||
|
||||
const issueColumns = [
|
||||
{
|
||||
title: 'ID', dataIndex: 'issueNo', key: 'issueNo', width: 160,
|
||||
render: (v: string) => <span className="font-mono font-medium text-blue-600">{v}</span>
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (s: string) => <Tag color={statusColor[s] || 'default'}>{s}</Tag>
|
||||
},
|
||||
{
|
||||
title: '优先级', dataIndex: 'priority', key: 'priority', width: 80,
|
||||
render: (p: string) => <Tag color={priorityColor[p] || 'default'}>{p}</Tag>
|
||||
},
|
||||
{ title: '工程阶段', dataIndex: 'phase', key: 'phase', width: 100, render: (v: string) => v || '-' },
|
||||
{ title: '对应者', dataIndex: 'assigneeName', key: 'assigneeName', width: 100, render: (v: string) => v || '-' },
|
||||
{ title: '截止日期', dataIndex: 'deadline', key: 'deadline', width: 120, render: (v: string) => v ? v.slice(0, 10) : '-' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={props.open}
|
||||
onCancel={props.onCancel}
|
||||
width={1000}
|
||||
footer={[
|
||||
<div key="footer" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<span>已选择 <b style={{ color: '#1677ff' }}>{selected.length}</b> 条指摘</span>
|
||||
<Space>
|
||||
<Button onClick={props.onCancel}>取消</Button>
|
||||
<Button type="primary" icon={<ThunderboltOutlined />} loading={generating} onClick={generate}>
|
||||
生成 AI 分析
|
||||
</Button>
|
||||
</Space>
|
||||
</div>,
|
||||
]}
|
||||
title={
|
||||
<div>
|
||||
选择指摘生成 AI 分析
|
||||
<Tag style={{ marginLeft: 8, fontWeight: 400 }}>可多选</Tag>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={issueForm} layout="inline" style={{ rowGap: 8, marginBottom: 12 }}>
|
||||
<Form.Item name="status" label="指摘状态">
|
||||
<Select allowClear placeholder="全部" style={{ width: 130 }} options={ISSUE_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phase" label="工程阶段">
|
||||
<Select allowClear placeholder="全部" style={{ width: 120 }} options={ISSUE_PHASE_OPTIONS.map(p => ({ label: p, value: p }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="departmentId" label="归属部门">
|
||||
<Select allowClear placeholder="全部" style={{ width: 130 }} options={props.departments.map(d => ({ label: d.name, value: d.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="搜索">
|
||||
<Input placeholder="ID / 标题" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="创建时间">
|
||||
<RangePicker format="YYYY/MM/DD" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { issueForm.resetFields(); loadIssues({ page: 1, pageSize }) }}>重置</Button>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={() => loadIssues({ page: 1, pageSize })}>查询</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
dataSource={issues}
|
||||
columns={issueColumns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected.map(s => s.id),
|
||||
onChange: (_keys, rows) => setSelected(rows),
|
||||
}}
|
||||
pagination={{
|
||||
current: page, total, pageSize,
|
||||
onChange: (p) => loadIssues({ page: p, pageSize }),
|
||||
showTotal: t => `共 ${t} 条`,
|
||||
}}
|
||||
style={{ maxHeight: 420, overflowY: 'auto' }}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Typography, Card, Row, Col, Statistic, Space
|
||||
} from 'antd'
|
||||
import {
|
||||
ThunderboltOutlined, BarChartOutlined,
|
||||
PieChartOutlined, LineChartOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Line, Pie, Column } from '@ant-design/charts'
|
||||
import {
|
||||
aiAnalysisApi, OverviewStats
|
||||
} from './services'
|
||||
|
||||
const ANALYSIS_LABELS: Record<string, string> = {
|
||||
pending: '待处理', processing: '分析中', completed: '已完成', failed: '失败'
|
||||
}
|
||||
|
||||
export default function AiOverviewTab() {
|
||||
const [stats, setStats] = useState<OverviewStats | null>(null)
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const res: any = await aiAnalysisApi.overviewStats(14)
|
||||
setStats(res.data)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
const statusCards = [
|
||||
{ title: '指摘总数', value: stats?.totalIssues ?? 0, color: '#1677ff', suffix: '条' },
|
||||
{ title: '已分析指摘', value: stats?.analyzedCount ?? 0, color: '#52c41a', suffix: '条' },
|
||||
{ title: '未分析指摘', value: stats?.unanalyzedCount ?? 0, color: '#fa8c16', suffix: '条' },
|
||||
{ title: '分析覆盖率', value: stats?.coverageRate ?? 0, color: '#722ed1', suffix: '%' },
|
||||
]
|
||||
|
||||
const lineData = (stats?.dailyTrend || []).flatMap(d => [
|
||||
{ date: d.date.slice(5), type: '已完成', value: d.completed },
|
||||
{ date: d.date.slice(5), type: '失败', value: d.failed },
|
||||
])
|
||||
|
||||
const statusPieData = (stats?.statusDistribution || []).map(s => ({
|
||||
type: ANALYSIS_LABELS[s.name] || s.name,
|
||||
value: s.value,
|
||||
}))
|
||||
|
||||
const categoryPieData = (stats?.categoryDistribution || []).map(s => ({
|
||||
type: s.name,
|
||||
value: s.value,
|
||||
}))
|
||||
|
||||
const columnData = (stats?.departmentDistribution || []).map(s => ({
|
||||
name: s.name,
|
||||
value: s.value,
|
||||
}))
|
||||
|
||||
const lineConfig = {
|
||||
xField: 'date',
|
||||
yField: 'value',
|
||||
seriesField: 'type',
|
||||
colorField: 'type',
|
||||
height: 280,
|
||||
smooth: true,
|
||||
scale: { y: { nice: true } },
|
||||
axis: { y: { title: false }, x: { title: false } },
|
||||
}
|
||||
|
||||
const pieConfig = {
|
||||
angleField: 'value',
|
||||
colorField: 'type',
|
||||
height: 280,
|
||||
innerRadius: 0.6,
|
||||
label: { text: 'value' },
|
||||
legend: { color: { position: 'bottom' } },
|
||||
tooltip: {
|
||||
items: [
|
||||
(arg: any) => ({ name: arg.type, value: arg.value })
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
const columnConfig = {
|
||||
xField: 'name',
|
||||
yField: 'value',
|
||||
height: 280,
|
||||
axis: { y: { title: false }, x: { title: false } },
|
||||
style: { maxWidth: 40 },
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<Row gutter={16}>
|
||||
{statusCards.map((c, i) => (
|
||||
<Col span={4} key={i}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={c.title}
|
||||
value={c.value}
|
||||
suffix={<span style={{ fontSize: 12, color: '#999' }}>{c.suffix}</span>}
|
||||
valueStyle={{ color: c.color, fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="分析成功率"
|
||||
value={stats?.successRate ?? 0}
|
||||
suffix={<span style={{ fontSize: 12, color: '#999' }}>%</span>}
|
||||
valueStyle={{ color: (stats?.successRate ?? 0) >= 80 ? '#52c41a' : '#fa8c16', fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title={<Space><PieChartOutlined style={{ color: '#1677ff' }} />根因分类分布</Space>}>
|
||||
{categoryPieData.length > 0
|
||||
? <Pie {...pieConfig} data={categoryPieData} />
|
||||
: <EmptyChart text="暂无已分析的分类数据" />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title={<Space><PieChartOutlined style={{ color: '#722ed1' }} />分析状态分布</Space>}>
|
||||
{statusPieData.length > 0
|
||||
? <Pie {...pieConfig} data={statusPieData} />
|
||||
: <EmptyChart text="暂无分析记录" />}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title={<Space><BarChartOutlined style={{ color: '#52c41a' }} />部门分析分布</Space>}>
|
||||
{columnData.length > 0
|
||||
? <Column {...columnConfig} data={columnData} />
|
||||
: <EmptyChart text="暂无已分析的部门数据" />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title={<Space><LineChartOutlined style={{ color: '#fa8c16' }} />近 14 天分析趋势</Space>}>
|
||||
{lineData.length > 0
|
||||
? <Line {...lineConfig} data={lineData} />
|
||||
: <EmptyChart text="暂无分析记录" />}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyChart({ text }: { text: string }) {
|
||||
return (
|
||||
<div style={{ height: 280, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import request from '../../request'
|
||||
|
||||
export interface AiAnalysisRecord {
|
||||
id: number
|
||||
issueId: number
|
||||
issueNo: string
|
||||
issueTitle: string
|
||||
departmentName?: string
|
||||
category: string
|
||||
keywords: string
|
||||
extractedKeywords: string
|
||||
rootCause: string
|
||||
suggestion: string
|
||||
status: string
|
||||
helpfulCount: number
|
||||
promptTemplateId: string
|
||||
promptVersion: number
|
||||
modelProvider: string
|
||||
modelName: string
|
||||
errorMessage: string
|
||||
startedAt: string
|
||||
completedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface BatchGenerateParams {
|
||||
issueIds?: number[]
|
||||
departmentId?: string
|
||||
status?: string
|
||||
phase?: string
|
||||
}
|
||||
|
||||
export interface RecordQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
id?: number
|
||||
issueId?: number
|
||||
departmentId?: number
|
||||
status?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export interface DepartmentOption {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface OverviewStats {
|
||||
totalIssues: number
|
||||
analyzedCount: number
|
||||
unanalyzedCount: number
|
||||
coverageRate: number
|
||||
successRate: number
|
||||
dailyTrend: { date: string; completed: number; failed: number }[]
|
||||
statusDistribution: { name: string; value: number }[]
|
||||
categoryDistribution: { name: string; value: number }[]
|
||||
departmentDistribution: { name: string; value: number }[]
|
||||
}
|
||||
|
||||
export interface UnanalyzedIssue {
|
||||
id: number
|
||||
issueNo: string
|
||||
title: string
|
||||
status: string
|
||||
priority: string
|
||||
phase: string
|
||||
departmentName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface UnanalyzedQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
status?: string
|
||||
phase?: string
|
||||
departmentId?: number
|
||||
keyword?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export interface IssueListItem {
|
||||
id: number
|
||||
issueNo: string
|
||||
title: string
|
||||
status: string
|
||||
priority: string
|
||||
phase: string
|
||||
departmentId: number
|
||||
departmentName: string
|
||||
assigneeName: string
|
||||
deadline: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface IssueListQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
status?: string
|
||||
phase?: string
|
||||
departmentId?: number
|
||||
keyword?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export const aiAnalysisApi = {
|
||||
batchGenerate: (params: BatchGenerateParams) =>
|
||||
request.post('/ai/batch-generate', params),
|
||||
|
||||
records: (params: RecordQuery) =>
|
||||
request.get('/ai/records', { params }),
|
||||
|
||||
running: () =>
|
||||
request.get('/ai/records/running'),
|
||||
|
||||
callLogs: () =>
|
||||
request.get('/ai/call-logs'),
|
||||
|
||||
exportRecords: (params: RecordQuery) =>
|
||||
request.get('/ai/records/export', { params, responseType: 'blob' }),
|
||||
|
||||
feedback: (id: number, isHelpful: boolean, comment?: string) =>
|
||||
request.post(`/ai/records/${id}/feedback`, { isHelpful, comment }),
|
||||
|
||||
overviewStats: (days?: number) =>
|
||||
request.get('/ai/overview-stats', { params: { days } }),
|
||||
|
||||
unanalyzedIssues: (params: UnanalyzedQuery) =>
|
||||
request.get('/ai/unanalyzed', { params }),
|
||||
}
|
||||
|
||||
export const departmentApi = {
|
||||
list: () => request.get('/departments'),
|
||||
}
|
||||
|
||||
export const issuesApi = {
|
||||
list: (params: IssueListQuery) =>
|
||||
request.get('/issues', { params }),
|
||||
}
|
||||
|
||||
export const ISSUE_PRIORITY_OPTIONS = [
|
||||
{ label: '高', value: 'high' },
|
||||
{ label: '中', value: 'medium' },
|
||||
{ label: '低', value: 'low' },
|
||||
]
|
||||
|
||||
export const ISSUE_STATUS_OPTIONS = [
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '待处理', value: 'open' },
|
||||
{ label: '进行中', value: 'in_progress' },
|
||||
{ label: '已解决', value: 'resolved' },
|
||||
{ label: '已验证', value: 'verified' },
|
||||
{ label: '已关闭', value: 'closed' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
]
|
||||
|
||||
export const ISSUE_PHASE_OPTIONS = ['需求', '设计', '编码', '测试', '部署', '运维']
|
||||
|
||||
export const ANALYSIS_STATUS_OPTIONS = [
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '分析中', value: 'processing' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Card, Table, Button, Space, Tag, Input, Select, Form, DatePicker, message
|
||||
} from 'antd'
|
||||
import { ThunderboltOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { aiAnalysisApi, departmentApi, UnanalyzedIssue, UnanalyzedQuery } from './services'
|
||||
import { STATUS_LABELS, STATUS_COLORS, PRIORITY_LABELS, PRIORITY_COLORS } from '../../constants/issue'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const pageSize = 10
|
||||
|
||||
export default function UnanalyzedTab() {
|
||||
const [items, setItems] = useState<UnanalyzedIssue[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<UnanalyzedIssue[]>([])
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [departments, setDepartments] = useState<{ id: number; name: string }[]>([])
|
||||
const [filterForm] = Form.useForm()
|
||||
const pollTimer = useRef<number | null>(null)
|
||||
|
||||
const loadUnanalyzed = async (q: UnanalyzedQuery) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await aiAnalysisApi.unanalyzedIssues(q)
|
||||
setItems(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(q.page)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUnanalyzed({ page: 1, pageSize })
|
||||
departmentApi.list().then((res: any) => setDepartments(res.data || [])).catch(() => {})
|
||||
return () => { if (pollTimer.current) window.clearTimeout(pollTimer.current) }
|
||||
}, [])
|
||||
|
||||
const buildQuery = (p: number): UnanalyzedQuery => {
|
||||
const v = filterForm.getFieldsValue()
|
||||
return {
|
||||
page: p,
|
||||
pageSize,
|
||||
status: v.status,
|
||||
phase: v.phase,
|
||||
departmentId: v.departmentId,
|
||||
keyword: v.keyword,
|
||||
startDate: v.dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: v.dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => loadUnanalyzed(buildQuery(1))
|
||||
|
||||
const handleReset = () => {
|
||||
filterForm.resetFields()
|
||||
loadUnanalyzed({ page: 1, pageSize })
|
||||
}
|
||||
|
||||
const refreshAll = () => loadUnanalyzed(buildQuery(page))
|
||||
|
||||
const analyze = async (issueIds: number[]) => {
|
||||
if (issueIds.length === 0) {
|
||||
message.warning('请至少选择一条指摘')
|
||||
return
|
||||
}
|
||||
setAnalyzing(true)
|
||||
try {
|
||||
const res: any = await aiAnalysisApi.batchGenerate({ issueIds })
|
||||
message.success(`已对 ${res.data?.submitted ?? issueIds.length} 条指摘启动 AI 分析`)
|
||||
setSelected([])
|
||||
if (pollTimer.current) window.clearTimeout(pollTimer.current)
|
||||
pollTimer.current = window.setTimeout(() => {
|
||||
refreshAll()
|
||||
if (pollTimer.current) window.clearTimeout(pollTimer.current)
|
||||
}, 3000)
|
||||
} catch { /* ignore */ }
|
||||
setAnalyzing(false)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID', dataIndex: 'issueNo', key: 'issueNo', width: 150,
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontWeight: 700, color: '#2563eb' }}>{v}</span>
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
|
||||
},
|
||||
{
|
||||
title: '优先级', dataIndex: 'priority', key: 'priority', width: 80,
|
||||
render: (p: string) => <Tag color={PRIORITY_COLORS[p] || 'default'}>{PRIORITY_LABELS[p] || p}</Tag>
|
||||
},
|
||||
{ title: '工程阶段', dataIndex: 'phase', key: 'phase', width: 110, render: (v: string) => v || '-' },
|
||||
{ title: '归属部门', dataIndex: 'departmentName', key: 'departmentName', width: 110, render: (v: string) => v || '-' },
|
||||
{ title: '对应者', dataIndex: 'assigneeName', key: 'assigneeName', width: 100, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 120,
|
||||
render: (v: string) => (v ? dayjs(v).format('MM-DD HH:mm') : '-')
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 90, align: 'right' as const,
|
||||
render: (_: any, r: UnanalyzedIssue) => (
|
||||
<Button type="link" size="small" icon={<ThunderboltOutlined />} loading={analyzing} onClick={() => analyze([r.id])}>
|
||||
分析
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ borderRadius: 16 }}
|
||||
title={
|
||||
<Space>
|
||||
<ThunderboltOutlined style={{ color: '#fa8c16' }} />
|
||||
<span>未分析指摘({total})</span>
|
||||
<span style={{ fontSize: 12, color: '#999', fontWeight: 400 }}>不含已生成分析结果的指摘,失败记录可在此重新分析</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={analyzing}
|
||||
disabled={selected.length === 0}
|
||||
onClick={() => analyze(selected.map(s => s.id))}
|
||||
>
|
||||
批量分析已选({selected.length})
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={filterForm} layout="inline" style={{ rowGap: 8, marginBottom: 12 }}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear placeholder="全部" style={{ width: 120 }}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ label, value }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="departmentId" label="部门">
|
||||
<Select allowClear placeholder="全部" style={{ width: 130 }}
|
||||
options={departments.map(d => ({ label: d.name, value: d.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phase" label="阶段">
|
||||
<Input placeholder="工程阶段" allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="搜索">
|
||||
<Input placeholder="编号 / 标题" allowClear style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="创建时间">
|
||||
<RangePicker format="YYYY/MM/DD" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>查询</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
<Table
|
||||
dataSource={items}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected.map(s => s.id),
|
||||
onChange: (_keys, rows) => setSelected(rows),
|
||||
}}
|
||||
pagination={{
|
||||
current: page, total, pageSize,
|
||||
onChange: (p) => loadUnanalyzed(buildQuery(p)),
|
||||
showTotal: t => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import {
|
||||
Typography, Upload, Table, Button, Card, Space, Tag, message,
|
||||
Modal, Statistic, Alert, Spin, Tooltip
|
||||
} from 'antd'
|
||||
import {
|
||||
DownloadOutlined, ArrowRightOutlined, CheckCircleOutlined,
|
||||
CloseCircleOutlined, InboxOutlined, RobotOutlined,
|
||||
CheckOutlined, ThunderboltOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { importApi, ImportRow, ImportPreview, ImportRecord, ImportSuggestion } from '../../api/system'
|
||||
|
||||
const validPriority = new Set(['high', 'medium', 'low'])
|
||||
|
||||
const priorityZh: Record<string, { label: string; color: string }> = {
|
||||
high: { label: '高', color: 'red' },
|
||||
medium: { label: '中', color: 'orange' },
|
||||
low: { label: '低', color: 'green' },
|
||||
}
|
||||
|
||||
const rowErrors = (r: ImportRow): string[] => {
|
||||
const errs: string[] = []
|
||||
if (!r.title?.trim()) errs.push('标题必填')
|
||||
if (r.priority?.trim() && !validPriority.has(r.priority.trim().toLowerCase())) errs.push('优先级必须是 high/medium/low')
|
||||
if (r.deadline?.trim() && !dayjs(r.deadline).isValid()) errs.push('期限格式不正确')
|
||||
return errs
|
||||
}
|
||||
|
||||
const recompute = (rows: ImportRow[]) => {
|
||||
let valid = 0
|
||||
for (const r of rows) {
|
||||
r.errors = rowErrors(r)
|
||||
r.status = r.errors.length === 0 ? 'ok' : 'error'
|
||||
if (r.errors.length === 0) valid++
|
||||
}
|
||||
return { rows, validCount: valid, errorCount: rows.length - valid }
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
phase: '工程阶段',
|
||||
priority: '优先级',
|
||||
title: '标题',
|
||||
deadline: '期限',
|
||||
}
|
||||
|
||||
const unFilledTag = <span style={{ background: '#fee2e2', color: '#ef4444', padding: '0 6px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>未填</span>
|
||||
|
||||
const tooltipStyle: CSSProperties = {
|
||||
background: '#fff', color: '#374151',
|
||||
border: '1px solid #eef2f7', borderRadius: 8,
|
||||
boxShadow: '0 8px 24px rgba(15,23,42,.12)', padding: '8px 12px',
|
||||
}
|
||||
|
||||
const Ellipsis = ({ value, fieldName, tooltipText, style, width = 200 }: {
|
||||
value?: string
|
||||
fieldName?: string
|
||||
tooltipText?: string
|
||||
style?: CSSProperties
|
||||
width?: number
|
||||
}) => {
|
||||
const text = tooltipText ?? value
|
||||
return (
|
||||
<Tooltip
|
||||
color="#fff"
|
||||
overlayInnerStyle={tooltipStyle}
|
||||
title={text ? (
|
||||
<div style={{ maxWidth: 320, fontSize: 12 }}>
|
||||
{fieldName && <div style={{ fontSize: 10, color: '#ea580c', fontWeight: 700, marginBottom: 4 }}>{fieldName}</div>}
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{text}</div>
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<span style={{
|
||||
maxWidth: width, overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap', display: 'inline-block', verticalAlign: 'bottom', ...style,
|
||||
}}>
|
||||
{value || '-'}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BatchInputPage() {
|
||||
const [preview, setPreview] = useState<ImportPreview | null>(null)
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
const [suggestions, setSuggestions] = useState<ImportSuggestion[]>([])
|
||||
const [applied, setApplied] = useState<Set<string>>(new Set())
|
||||
const [agentLoading, setAgentLoading] = useState(false)
|
||||
const [agentEngine, setAgentEngine] = useState('')
|
||||
const [agentUsable, setAgentUsable] = useState(true)
|
||||
const [agentUsableReason, setAgentUsableReason] = useState('')
|
||||
|
||||
const [records, setRecords] = useState<ImportRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [errorOpen, setErrorOpen] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
|
||||
const loadRecords = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await importApi.records(p)
|
||||
setRecords(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { loadRecords() }, [])
|
||||
|
||||
const handleTemplate = async () => {
|
||||
try {
|
||||
const res: any = await importApi.template()
|
||||
const url = URL.createObjectURL(res as Blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'レビュー記録表.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const applySuggestion = (s: ImportSuggestion) => {
|
||||
if (!preview) return
|
||||
const key = `${s.rowNo}:${s.field}`
|
||||
if (applied.has(key) || !s.suggested) return
|
||||
const rows = preview.rows.map((r) => {
|
||||
if (r.rowNo === s.rowNo && s.field && s.suggested) {
|
||||
return { ...r, [s.field]: s.suggested }
|
||||
}
|
||||
return r
|
||||
})
|
||||
const next = recompute(rows)
|
||||
setPreview({ ...preview, ...next })
|
||||
setApplied((prev) => new Set(prev).add(key))
|
||||
}
|
||||
|
||||
const applyAll = () => {
|
||||
if (!preview || suggestions.length === 0) return
|
||||
let rows = preview.rows
|
||||
const nextApplied = new Set(applied)
|
||||
for (const s of suggestions) {
|
||||
const key = `${s.rowNo}:${s.field}`
|
||||
if (nextApplied.has(key) || !s.suggested) continue
|
||||
rows = rows.map((r) => (r.rowNo === s.rowNo && s.field ? { ...r, [s.field]: s.suggested } : r))
|
||||
nextApplied.add(key)
|
||||
}
|
||||
const next = recompute(rows)
|
||||
setPreview({ ...preview, ...next })
|
||||
setApplied(nextApplied)
|
||||
}
|
||||
|
||||
const handlePreview = async (file: File) => {
|
||||
if (!/\.(xlsx|xls)$/i.test(file.name)) {
|
||||
message.error('仅支持 .xlsx / .xls 文件')
|
||||
return false
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
message.error('单个文件不能超过 50MB')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const res: any = await importApi.preview(file)
|
||||
const p: ImportPreview = res.data
|
||||
setPreview(p)
|
||||
setFileName(file.name)
|
||||
setSuggestions([])
|
||||
setApplied(new Set())
|
||||
setAgentEngine('')
|
||||
setAgentUsable(true)
|
||||
setAgentUsableReason('')
|
||||
if (p.headerValid === false) {
|
||||
setAgentUsable(false)
|
||||
setAgentUsableReason('导入文件的表头与标准的「レビュー記録表」模板不一致,不是标准模板。请下载并使用正确的模板后重新上传。')
|
||||
message.warning('不是标准的レビュー記録表模板,请使用正确模板')
|
||||
return false
|
||||
}
|
||||
message.success('解析完成,Agent 正在智能校验数据...')
|
||||
setAgentLoading(true)
|
||||
try {
|
||||
const ar: any = await importApi.aiValidate(p.rows)
|
||||
setSuggestions(ar.data?.suggestions || [])
|
||||
setAgentEngine(ar.data?.engine || '')
|
||||
setAgentUsable(ar.data?.usable ?? true)
|
||||
setAgentUsableReason(ar.data?.usableReason || '')
|
||||
if (ar.data?.usable === false) {
|
||||
message.warning(ar.data?.usableReason || '该文件不能作为指摘表导入')
|
||||
} else if ((ar.data?.suggestions || []).length === 0) {
|
||||
message.success('Agent 校验完成,数据全部通过')
|
||||
}
|
||||
} catch {
|
||||
setSuggestions([])
|
||||
setAgentEngine('')
|
||||
setAgentUsable(true)
|
||||
setAgentUsableReason('')
|
||||
message.warning('AI 校验超时或不可用,已降级为规则校验')
|
||||
}
|
||||
setAgentLoading(false)
|
||||
} catch (err: any) {
|
||||
setAgentLoading(false)
|
||||
const msg = err?.response?.data?.message
|
||||
if (msg) {
|
||||
message.error(msg)
|
||||
} else if (err?.message?.includes('timeout')) {
|
||||
message.error('解析超时,请稍后重试')
|
||||
} else {
|
||||
message.error('解析失败,请使用标准模板')
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!preview) return
|
||||
setImporting(true)
|
||||
try {
|
||||
const res: any = await importApi.confirm(fileName, preview.rows)
|
||||
const r = res.data
|
||||
message.success(`导入完成:成功 ${r.successCount} 条,失败 ${r.failCount} 条`)
|
||||
setPreview(null)
|
||||
setFileName('')
|
||||
setSuggestions([])
|
||||
setApplied(new Set())
|
||||
setAgentEngine('')
|
||||
setAgentUsable(true)
|
||||
setAgentUsableReason('')
|
||||
loadRecords(1)
|
||||
} catch {
|
||||
message.error('导入失败')
|
||||
}
|
||||
setImporting(false)
|
||||
}
|
||||
|
||||
const statusTag = (s: string) => {
|
||||
const map: Record<string, { color: string; label: string }> = {
|
||||
success: { color: 'green', label: '已完成' },
|
||||
partial: { color: 'orange', label: '部分成功' },
|
||||
failed: { color: 'red', label: '失败' },
|
||||
}
|
||||
const item = map[s] || { color: 'default', label: s }
|
||||
return <Tag color={item.color}>{item.label}</Tag>
|
||||
}
|
||||
|
||||
const correctedCell = (rowNo: number | undefined, field: string, value?: string) => {
|
||||
if (rowNo != null && applied.has(`${rowNo}:${field}`)) {
|
||||
return <span style={{ color: '#ef4444', fontWeight: 700, textDecoration: 'underline wavy #fca5a5' }}>{value}</span>
|
||||
}
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
const previewColumns: ColumnsType<ImportRow> = [
|
||||
{
|
||||
title: '行号', dataIndex: 'rowNo', key: 'rowNo', width: 56,
|
||||
render: (v?: number) => <span style={{ fontWeight: 700, color: '#9ca3af' }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '标题', dataIndex: 'title', key: 'title', width: 220,
|
||||
render: (v: string) => (v
|
||||
? <Ellipsis value={v} fieldName="标题" width={200} style={{ fontWeight: 500, color: '#374151' }} />
|
||||
: unFilledTag),
|
||||
},
|
||||
{
|
||||
title: '工程阶段', dataIndex: 'phase', key: 'phase', width: 110,
|
||||
render: (v: string, r) => {
|
||||
const appliedNow = r.rowNo != null && applied.has(`${r.rowNo}:phase`)
|
||||
if (appliedNow) {
|
||||
return <span style={{ color: '#ef4444', fontWeight: 700, textDecoration: 'underline wavy #fca5a5' }}>{v}</span>
|
||||
}
|
||||
return v ? <Ellipsis value={v} fieldName="工程阶段" width={90} /> : <span style={{ color: '#9ca3af' }}>-</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '优先级', dataIndex: 'priority', key: 'priority', width: 84,
|
||||
render: (v: string | undefined, r: ImportRow) => {
|
||||
if (!v) return unFilledTag
|
||||
const zh = priorityZh[v.toLowerCase()] || { label: v, color: 'default' }
|
||||
const isApplied = r.rowNo != null && applied.has(`${r.rowNo}:priority`)
|
||||
return <Tag color={zh.color} style={isApplied ? { fontWeight: 700 } : undefined}>{zh.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '期限', dataIndex: 'deadline', key: 'deadline', width: 108,
|
||||
render: (v?: string) => (v
|
||||
? <Ellipsis value={dayjs(v).format('YYYY-MM-DD')} tooltipText={v} fieldName="期限" width={90} />
|
||||
: <span style={{ color: '#9ca3af' }}>-</span>),
|
||||
},
|
||||
{
|
||||
title: '实施日', dataIndex: 'reviewDate', key: 'reviewDate', width: 108,
|
||||
render: (v?: string) => (v
|
||||
? <Ellipsis value={dayjs(v).format('YYYY-MM-DD')} tooltipText={v} fieldName="实施日" width={90} />
|
||||
: <span style={{ color: '#9ca3af' }}>-</span>),
|
||||
},
|
||||
{
|
||||
title: '描述', dataIndex: 'description', key: 'description', width: 220,
|
||||
render: (v?: string, r?: ImportRow) => (v
|
||||
? <Ellipsis value={v} tooltipText={v} fieldName="描述" width={200} />
|
||||
: <span style={{ color: '#9ca3af' }}>-</span>),
|
||||
},
|
||||
{
|
||||
title: '担当者', dataIndex: 'assigneeUserid', key: 'assigneeUserid', width: 90,
|
||||
render: (_: string, r) => (r.assigneeUserid
|
||||
? <Ellipsis value={r.assigneeUserid} tooltipText={`工号:${r.assigneeUserid}${r.departmentName ? `\n部门:${r.departmentName}` : ''}`} fieldName="担当者" width={80} />
|
||||
: <span style={{ color: '#9ca3af' }}>未分配</span>),
|
||||
},
|
||||
{ title: '部门', dataIndex: 'departmentName', key: 'departmentName', width: 90, render: (v?: string) => (v ? <Ellipsis value={v} fieldName="部门" width={80} /> : <span style={{ color: '#9ca3af' }}>-</span>) },
|
||||
{
|
||||
title: '校验', dataIndex: 'status', key: 'status', width: 86,
|
||||
render: (s?: string) => s === 'ok'
|
||||
? <span style={{ color: '#52c41a' }}><CheckCircleOutlined /> 已就绪</span>
|
||||
: <span style={{ color: '#fa8c16' }}><CloseCircleOutlined /> 待修正</span>,
|
||||
},
|
||||
]
|
||||
|
||||
const recordColumns: ColumnsType<ImportRecord> = [
|
||||
{
|
||||
title: '导入时间', dataIndex: 'createdAt', key: 'createdAt', width: 170,
|
||||
render: (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||||
},
|
||||
{ title: '文件名', dataIndex: 'fileName', key: 'fileName', ellipsis: true },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 110 },
|
||||
{ title: '总条数', dataIndex: 'totalCount', key: 'totalCount', width: 80 },
|
||||
{ title: '成功', dataIndex: 'successCount', key: 'successCount', width: 70, render: (v: number) => <span style={{ color: '#52c41a' }}>{v}</span> },
|
||||
{ title: '失败', dataIndex: 'failCount', key: 'failCount', width: 70, render: (v: number) => <span style={{ color: v > 0 ? '#f5222d' : '#999' }}>{v}</span> },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (s: string) => statusTag(s) },
|
||||
{
|
||||
title: '操作', key: 'action', width: 110,
|
||||
render: (_: unknown, r: ImportRecord) => (
|
||||
<Button
|
||||
type="link" size="small" disabled={!r.errorLog}
|
||||
onClick={() => { setErrorText(r.errorLog || ''); setErrorOpen(true) }}
|
||||
>
|
||||
错误详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const pendingSuggestions = useMemo(() => suggestions.filter((s) => !applied.has(`${s.rowNo}:${s.field}`)), [suggestions, applied])
|
||||
const fixSuggestions = useMemo(() => pendingSuggestions.filter((s) => s.level !== 'error' && s.suggested), [pendingSuggestions])
|
||||
const errorSuggestions = useMemo(() => pendingSuggestions.filter((s) => s.level === 'error'), [pendingSuggestions])
|
||||
const hasFix = fixSuggestions.length > 0
|
||||
const allPassed = preview != null && preview.errorCount === 0 && suggestions.length === 0
|
||||
|
||||
const agentBadge = () => {
|
||||
if (agentLoading) return <Tag color="processing">校验中...</Tag>
|
||||
if (!preview) return <Tag>待上传</Tag>
|
||||
if (agentUsable === false) return <Tag color="error">文件不可用</Tag>
|
||||
if (preview.errorCount > 0) return <Tag color="orange">发现 {preview.errorCount} 条待修正</Tag>
|
||||
if (suggestions.length > 0) return <Tag color="orange">发现 {pendingSuggestions.length} 个建议</Tag>
|
||||
return <Tag color="green">校验通过</Tag>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>指摘批量录入</Typography.Title>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleTemplate}>下载导入模板</Button>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Upload.Dragger
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
beforeUpload={handlePreview}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||
<p className="ant-upload-hint">支持 .xlsx, .xls 文件,单个文件不超过 50MB · 上传后 Agent 自动智能校验</p>
|
||||
</Upload.Dragger>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(320px, 1fr) 2fr', gap: 16, marginBottom: 16, alignItems: 'start' }}>
|
||||
{/* Agent 校验结果面板 */}
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: 16, border: '1px solid #f1f5f9',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.03)', padding: 20,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<RobotOutlined style={{ color: '#1a73e8', fontSize: 18 }} />
|
||||
<Typography.Text strong>Agent 校验结果</Typography.Text>
|
||||
</div>
|
||||
{agentBadge()}
|
||||
</div>
|
||||
|
||||
{!preview ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 12px' }}>
|
||||
<RobotOutlined style={{ fontSize: 36, color: '#e5e7eb' }} />
|
||||
<p style={{ color: '#9ca3af', fontSize: 13, marginTop: 12, marginBottom: 0 }}>
|
||||
上传 Excel 文件后<br />Agent 将自动校验数据
|
||||
</p>
|
||||
</div>
|
||||
) : agentLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<Spin />
|
||||
<p style={{ color: '#999', fontSize: 12, marginTop: 8 }}>Agent 正在扫描数据并生成修正建议...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{agentUsable === false && (
|
||||
<div style={{ padding: 16, background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 12, display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CloseCircleOutlined style={{ color: '#ef4444', fontSize: 18, marginTop: 2 }} />
|
||||
<div>
|
||||
<Typography.Text strong style={{ color: '#b91c1c' }}>该文件不能作为指摘表使用</Typography.Text>
|
||||
<div style={{ color: '#dc2626', fontSize: 12, marginTop: 4 }}>{agentUsableReason}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorSuggestions.map((s) => {
|
||||
const key = `${s.rowNo}:${s.field}`
|
||||
const label = s.fieldName || fieldLabels[s.field || ''] || s.field
|
||||
return (
|
||||
<div key={key} style={{
|
||||
padding: 16, background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 12,
|
||||
position: 'relative',
|
||||
}}>
|
||||
<span style={{ position: 'absolute', right: 12, top: 12, fontSize: 10, fontWeight: 700, color: '#f87171' }}>
|
||||
第 {s.rowNo} 行
|
||||
</span>
|
||||
<p style={{ margin: 0, fontSize: 13, fontWeight: 700, color: '#b91c1c' }}>
|
||||
{label}:{s.original || '未填写'}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 8px', fontSize: 11, color: 'rgba(185,28,28,.7)' }}>原因:{s.reason}</p>
|
||||
<Button
|
||||
block size="small" disabled
|
||||
style={{ marginTop: 10, background: '#fef2f2', borderColor: '#fecaca', color: '#9ca3af', fontSize: 11, fontWeight: 700, borderRadius: 8 }}
|
||||
>
|
||||
无法自动修正
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{fixSuggestions.map((s) => {
|
||||
const key = `${s.rowNo}:${s.field}`
|
||||
const appliedNow = applied.has(key)
|
||||
const label = s.fieldName || fieldLabels[s.field || ''] || s.field
|
||||
return (
|
||||
<div key={key} style={{
|
||||
padding: 16, background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 12,
|
||||
position: 'relative',
|
||||
}}>
|
||||
<span style={{ position: 'absolute', right: 12, top: 12, fontSize: 10, fontWeight: 700, color: '#fb923c' }}>
|
||||
第 {s.rowNo} 行
|
||||
</span>
|
||||
<p style={{ margin: 0, fontSize: 13, fontWeight: 700, color: '#c2410c' }}>
|
||||
{label}:{s.original ? `${s.original} → ` : ''}{s.suggested}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 8px', fontSize: 11, color: 'rgba(194,65,12,.7)' }}>原因:{s.reason}</p>
|
||||
<Button
|
||||
block size="small" disabled={appliedNow}
|
||||
onClick={() => applySuggestion(s)}
|
||||
style={{
|
||||
marginTop: 10, background: appliedNow ? '#f0fdf4' : '#fff',
|
||||
borderColor: appliedNow ? '#bbf7d0' : '#fdba74', color: appliedNow ? '#16a34a' : '#ea580c',
|
||||
fontSize: 11, fontWeight: 700, borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{appliedNow ? (<><CheckOutlined /> 已应用</>) : '应用修正'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{allPassed && agentUsable !== false && (
|
||||
<div style={{ padding: 16, background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#22c55e', fontSize: 18 }} />
|
||||
<div>
|
||||
<Typography.Text strong style={{ color: '#15803d' }}>所有数据校验通过</Typography.Text>
|
||||
<div style={{ color: '#4ade80', fontSize: 12 }}>Agent 已完成自动关联与去重校验。</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFix && (
|
||||
<Button
|
||||
block
|
||||
style={{
|
||||
background: '#111827', color: '#fff', fontWeight: 700, height: 40,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
onClick={applyAll}
|
||||
>
|
||||
<ThunderboltOutlined /> 一键应用全部修正 ({fixSuggestions.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{agentEngine && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', textAlign: 'center' }}>
|
||||
{agentEngine === 'ai' ? '引擎:AI 智能模型' : '引擎:规则引擎'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预检表格 */}
|
||||
<Card
|
||||
size="small" className="preview-table"
|
||||
title={preview ? `预检数据预览 (${preview.total} 条) · ${fileName}` : '预检数据预览'}
|
||||
extra={preview && (
|
||||
<Space>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
|
||||
<span style={{ width: 8, height: 8, background: '#fb923c', borderRadius: '50%' }} /> 待修正
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
|
||||
<span style={{ width: 8, height: 8, background: '#4ade80', borderRadius: '50%' }} /> 已就绪
|
||||
</span>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{preview && preview.errorCount > 0 && (
|
||||
<Alert
|
||||
type="warning" showIcon style={{ marginBottom: 12 }}
|
||||
message={`有 ${preview.errorCount} 行数据待修正`}
|
||||
description="可通过左侧 Agent 建议一键修正,或直接导入已就绪数据。"
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
rowKey={(r) => String(r.rowNo)}
|
||||
columns={previewColumns}
|
||||
dataSource={preview?.rows || []}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ y: 380 }}
|
||||
rowClassName={(r) => (r.status === 'error' ? 'import-row-error' : '')}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div style={{ padding: '48px 0' }}>
|
||||
<InboxOutlined style={{ fontSize: 36, color: '#e5e7eb' }} />
|
||||
<p style={{ color: '#9ca3af', fontSize: 13, marginTop: 12, marginBottom: 0 }}>尚未上传文件</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 16 }}>
|
||||
{preview && (
|
||||
<Statistic title="已就绪" value={preview.validCount} valueStyle={{ fontSize: 16, color: '#16a34a' }} />
|
||||
)}
|
||||
<Button
|
||||
type="primary" icon={<ArrowRightOutlined />} loading={importing}
|
||||
disabled={!preview || preview.rows.length === 0 || agentUsable === false}
|
||||
onClick={handleConfirm}
|
||||
style={{ background: '#1a73e8', borderRadius: 12, height: 40, fontWeight: 700, boxShadow: '0 4px 12px rgba(26,115,232,.2)' }}
|
||||
>
|
||||
确认并正式导入 ({preview?.validCount ?? 0} 条)
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
size="small" title="历史导入记录"
|
||||
extra={<Typography.Text type="secondary">共 {total} 条</Typography.Text>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id" loading={loading} columns={recordColumns} dataSource={records}
|
||||
size="middle"
|
||||
pagination={{
|
||||
current: page, pageSize: 20, total,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p) => loadRecords(p),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="导入错误详情" open={errorOpen} footer={null}
|
||||
onCancel={() => setErrorOpen(false)} width={640}
|
||||
>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', maxHeight: 400, overflow: 'auto', background: '#fafafa', padding: 12, borderRadius: 6 }}>
|
||||
{errorText}
|
||||
</pre>
|
||||
</Modal>
|
||||
|
||||
<style>{`
|
||||
.preview-table .ant-table-thead > tr > th {
|
||||
background: #f9fafb !important;
|
||||
color: #9ca3af !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 11px;
|
||||
letter-spacing: .04em;
|
||||
border-bottom: 1px solid #f3f4f6 !important;
|
||||
}
|
||||
.preview-table .ant-table-tbody > tr:hover > td { background: #f9fafb !important; }
|
||||
.preview-table .ant-table-tbody > tr > td { border-bottom: 1px solid #f9fafb !important; }
|
||||
.import-row-error td { background: rgba(255, 237, 213, 0.4) !important; }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Typography, Card, Row, Col, Statistic, List, Tag, Input, Button, Space, Modal } from 'antd'
|
||||
import { message } from '../../antdStatic'
|
||||
import {
|
||||
AlertOutlined, LoadingOutlined, CheckCircleOutlined, PlusOutlined,
|
||||
SendOutlined, InboxOutlined, RobotOutlined, OrderedListOutlined, EyeOutlined, CloseCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Line, Pie } from '@ant-design/charts'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getDashboardStats, DashboardStats } from './services'
|
||||
import { listIssues, executeAgent } from '../issues/services'
|
||||
import { STATUS_LABELS, ACTION_LABELS } from '../../constants/issue'
|
||||
|
||||
const statCards = [
|
||||
{ key: 'pendingCount', title: '待处理指摘', color: '#fa8c16', icon: <AlertOutlined /> },
|
||||
{ key: 'inProgressCount', title: '进行中指摘', color: '#1677ff', icon: <LoadingOutlined /> },
|
||||
{ key: 'monthlyClosedCount', title: '本月已完成', color: '#52c41a', icon: <CheckCircleOutlined /> },
|
||||
{ key: 'todayNewCount', title: '今日新增', color: '#722ed1', icon: <PlusOutlined /> }
|
||||
]
|
||||
|
||||
const QUICK_CMDS = [
|
||||
{ label: '催办逾期指摘', goal: '检索知识库相似案例,生成对应方案并催办逾期指摘' },
|
||||
{ label: '生成本周报告', goal: '生成本周指摘处理统计报告' },
|
||||
{ label: '分配待处理指摘', goal: '为待处理指摘分配对应者并生成对应方案' },
|
||||
{ label: '检索知识库', goal: '检索知识库相似案例,生成对应方案' }
|
||||
]
|
||||
|
||||
interface StreamItem {
|
||||
type: string
|
||||
content?: string
|
||||
tool?: string
|
||||
params?: string
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [agentGoal, setAgentGoal] = useState('')
|
||||
const [agentRunning, setAgentRunning] = useState(false)
|
||||
const [planModalOpen, setPlanModalOpen] = useState(false)
|
||||
const [planId, setPlanId] = useState<number>()
|
||||
const [streamItems, setStreamItems] = useState<StreamItem[]>([])
|
||||
const [planRequiresApproval, setPlanRequiresApproval] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getDashboardStats().then((res: any) => setStats(res.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAgentCommand = async () => {
|
||||
const goal = agentGoal.trim()
|
||||
if (!goal) return message.warning('请输入指令')
|
||||
setAgentRunning(true)
|
||||
setPlanRequiresApproval(false)
|
||||
try {
|
||||
let contextId: number | undefined
|
||||
const issueNoMatch = goal.match(/ISSUE-\d{4}-\d{3,}/i)
|
||||
if (issueNoMatch) {
|
||||
try {
|
||||
const res: any = await listIssues({ page: 1, pageSize: 20, keyword: issueNoMatch[0] })
|
||||
const hit = (res.data?.items || []).find((i: any) => i.issueNo === issueNoMatch[0])
|
||||
contextId = hit?.id
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const res: any = await executeAgent(contextId, goal)
|
||||
const pid = res.data?.planId
|
||||
if (!pid) {
|
||||
message.success('Agent 已解析指令并开始执行')
|
||||
if (contextId) navigate(`/issues/${contextId}`)
|
||||
setAgentRunning(false)
|
||||
return
|
||||
}
|
||||
setPlanId(pid)
|
||||
setStreamItems([{ type: 'thought', content: '指令已提交,正在连接执行流…' }])
|
||||
setPlanModalOpen(true)
|
||||
setAgentGoal('')
|
||||
if (contextId) navigate(`/issues/${contextId}`)
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const es = new EventSource(`/api/v1/agent/plan/${pid}/stream?token=${token}`)
|
||||
es.onmessage = (event) => {
|
||||
let ev: any = {}
|
||||
try { ev = JSON.parse(event.data) } catch { return }
|
||||
switch (ev.type) {
|
||||
case 'thought':
|
||||
setStreamItems(prev => [...prev, { type: 'thought', content: ev.content }])
|
||||
break
|
||||
case 'action':
|
||||
setStreamItems(prev => [...prev, {
|
||||
type: 'action', tool: ev.tool, params: ev.params ? JSON.stringify(ev.params) : ''
|
||||
}])
|
||||
if (ev.approval) {
|
||||
setPlanRequiresApproval(true)
|
||||
setStreamItems(prev => [...prev, { type: 'thought', content: '写操作需人工审批,请前往审批中心处理。' }])
|
||||
}
|
||||
break
|
||||
case 'observation':
|
||||
setStreamItems(prev => [...prev, { type: 'observation', content: ev.result || '' }])
|
||||
break
|
||||
case 'result':
|
||||
setStreamItems(prev => [...prev, { type: 'result', content: ev.content || '方案已生成' }])
|
||||
es.close()
|
||||
setAgentRunning(false)
|
||||
break
|
||||
case 'error':
|
||||
setStreamItems(prev => [...prev, { type: 'error', content: ev.message || 'Agent 执行失败' }])
|
||||
es.close()
|
||||
setAgentRunning(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
setAgentRunning(false)
|
||||
}
|
||||
} catch { /* 错误由拦截器提示 */ }
|
||||
setAgentRunning(false)
|
||||
}
|
||||
|
||||
const lineData = (stats?.trend || []).flatMap(d => [
|
||||
{ date: d.date.slice(5), type: '新增', value: d.newCount },
|
||||
{ date: d.date.slice(5), type: '已解决', value: d.resolvedCount }
|
||||
])
|
||||
|
||||
const pieData = (stats?.statusDistribution || []).map(s => ({
|
||||
type: STATUS_LABELS[s.status] || s.status,
|
||||
value: s.count
|
||||
}))
|
||||
|
||||
const lineConfig = {
|
||||
xField: 'date',
|
||||
yField: 'value',
|
||||
seriesField: 'type',
|
||||
colorField: 'type',
|
||||
height: 320,
|
||||
smooth: true,
|
||||
scale: { y: { nice: true } },
|
||||
axis: { y: { title: false }, x: { title: false } }
|
||||
}
|
||||
|
||||
const pieConfig = {
|
||||
angleField: 'value',
|
||||
colorField: 'type',
|
||||
height: 280,
|
||||
innerRadius: 0.6,
|
||||
label: { text: 'value' },
|
||||
legend: { color: { position: 'bottom' } },
|
||||
tooltip: {
|
||||
items: [
|
||||
(arg: any) => ({ name: arg.type, value: arg.value })
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const insightColor: Record<string, string> = {
|
||||
'high-risk': '#fa8c16',
|
||||
suggestion: '#1677ff',
|
||||
reminder: '#52c41a'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>工作台概览</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#e6f4ff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<RobotOutlined style={{ color: '#1677ff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>Agent 快捷指令</Typography.Text>
|
||||
<div style={{ fontSize: 11, color: '#999' }}>输入自然语言指令,Agent 将检索知识库相似案例并生成对应方案</div>
|
||||
</div>
|
||||
<Tag color="green" style={{ marginLeft: 'auto' }}>在线</Tag>
|
||||
</Space>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<SendOutlined style={{ color: '#bbb' }} />}
|
||||
placeholder="例如:查找知识库相似案例,生成对应方案"
|
||||
value={agentGoal}
|
||||
onChange={e => setAgentGoal(e.target.value)}
|
||||
onPressEnter={handleAgentCommand}
|
||||
/>
|
||||
<Button type="primary" loading={agentRunning} onClick={handleAgentCommand}>执行</Button>
|
||||
</Space.Compact>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => navigate('/issues/new')}>新建指摘</Button>
|
||||
<Button size="small" icon={<OrderedListOutlined />} onClick={() => navigate('/batch-input')}>批量录入</Button>
|
||||
<span style={{ fontSize: 11, color: '#999', marginLeft: 4 }}>快捷指令:</span>
|
||||
{QUICK_CMDS.map(c => (
|
||||
<Tag key={c.label} style={{ cursor: 'pointer' }} onClick={() => setAgentGoal(c.goal)}>{c.label}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
{statCards.map((c, i) => {
|
||||
const card = (stats?.cards || []).find(x => x.key === c.key)
|
||||
return (
|
||||
<Col span={6} key={i}>
|
||||
<Card>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 8, background: `${c.color}1a`, color: c.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, marginBottom: 8 }}>
|
||||
{c.icon}
|
||||
</div>
|
||||
<Statistic title={c.title} value={card?.count ?? (stats as any)?.[c.key] ?? 0} />
|
||||
{card?.changeText && <div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>{card.changeText}</div>}
|
||||
{card?.suggestion && (
|
||||
<div style={{ fontSize: 12, color: c.color, background: `${c.color}14`, borderRadius: 6, padding: '6px 8px', marginTop: 8 }}>
|
||||
{card.suggestion}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={16}>
|
||||
<Card title="指摘处理趋势">
|
||||
<Line {...lineConfig} data={lineData} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card title="Agent 洞察" style={{ height: '100%' }}>
|
||||
<List
|
||||
dataSource={stats?.insights || []}
|
||||
renderItem={(item) => (
|
||||
<List.Item style={{ alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ width: 4, height: 32, borderRadius: 2, background: insightColor[item.type] || '#fa8c16', marginTop: 4 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>{item.title}</div>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>{item.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="最新动态" extra={<Button type="link" size="small" onClick={() => navigate('/issues')}>查看全部</Button>}>
|
||||
<List
|
||||
dataSource={stats?.recentActivities || []}
|
||||
renderItem={(a, i) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: i % 2 ? '#e6f4ff' : '#f5f5f5', display: 'flex', alignItems: 'center', justifyContent: 'center', color: i % 2 ? '#1677ff' : '#999' }}>
|
||||
{i % 2 ? <RobotOutlined /> : <InboxOutlined />}
|
||||
</div>
|
||||
}
|
||||
title={<span>{a.userName || (i % 2 ? 'IMS Agent' : '')} <span style={{ fontWeight: i % 2 ? 600 : 400, color: i % 2 ? '#1677ff' : '#595959' }}>{ACTION_LABELS[a.action] || a.action}</span></span>}
|
||||
description={
|
||||
<div>
|
||||
<div>{a.issueNo} · {a.title}</div>
|
||||
<div style={{ fontSize: 11, color: '#bbb', marginTop: 2 }}>{a.createdAt?.replace('T', ' ').slice(0, 16)}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="指摘状态分布">
|
||||
<Pie {...pieConfig} data={pieData} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title={`Agent 执行流${planId ? `(计划 #${planId})` : ''}`}
|
||||
open={planModalOpen}
|
||||
onCancel={() => { setPlanModalOpen(false); setPlanId(undefined); setStreamItems([]) }}
|
||||
footer={
|
||||
<Space>
|
||||
{planRequiresApproval && <Button type="primary" onClick={() => navigate('/system/agent-admin')}>去审批中心</Button>}
|
||||
<Button onClick={() => { setPlanModalOpen(false); setPlanId(undefined); setStreamItems([]) }}>关闭</Button>
|
||||
</Space>
|
||||
}
|
||||
width={640}
|
||||
>
|
||||
<div style={{ maxHeight: 420, overflowY: 'auto', fontSize: 13 }}>
|
||||
{streamItems.map((item, i) => {
|
||||
if (item.type === 'action') {
|
||||
return (
|
||||
<div key={i} style={{ borderLeft: '3px solid #fa8c16', background: '#fffbe6', borderRadius: 8, padding: 10, marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: '#d46b08', marginBottom: 4 }}>
|
||||
<EyeOutlined style={{ marginRight: 4 }} />行动:{item.tool}
|
||||
</div>
|
||||
{item.params && <div style={{ fontFamily: 'monospace', fontSize: 11, wordBreak: 'break-all' }}>{item.params}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const color = item.type === 'error' ? '#cf1322' : item.type === 'result' ? '#389e0d' : item.type === 'observation' ? '#666' : '#595959'
|
||||
const bg = item.type === 'error' ? '#fff1f0' : item.type === 'result' ? '#f6ffed' : item.type === 'observation' ? '#fafafa' : 'transparent'
|
||||
return (
|
||||
<div key={i} style={{ background: bg, borderRadius: 6, padding: item.type === 'result' || item.type === 'error' ? 10 : 4, marginBottom: 8, color }}>
|
||||
{item.type === 'error' && <CloseCircleOutlined style={{ marginRight: 4 }} />}
|
||||
{item.content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import request from '../../request'
|
||||
|
||||
export interface DailyTrend {
|
||||
date: string
|
||||
newCount: number
|
||||
resolvedCount: number
|
||||
}
|
||||
|
||||
export interface StatusCount {
|
||||
status: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
userName: string
|
||||
action: string
|
||||
issueNo: string
|
||||
title: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface Insight {
|
||||
type: string
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface StatCardInfo {
|
||||
key: string
|
||||
count: number
|
||||
changeText: string
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
pendingCount: number
|
||||
inProgressCount: number
|
||||
pendingConfirmCount: number
|
||||
closedCount: number
|
||||
todayNewCount: number
|
||||
monthlyClosedCount: number
|
||||
cards: StatCardInfo[]
|
||||
trend: DailyTrend[]
|
||||
statusDistribution: StatusCount[]
|
||||
recentActivities: Activity[]
|
||||
insights: Insight[]
|
||||
}
|
||||
|
||||
export const getDashboardStats = () =>
|
||||
request.get('/dashboard/stats')
|
||||
@@ -0,0 +1,285 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import {
|
||||
Form, Input, Select, InputNumber, DatePicker, Radio, Card, Row, Col, Button, Space, Typography, Tag
|
||||
} from 'antd'
|
||||
import { message } from '../../antdStatic'
|
||||
import { FileTextOutlined, MessageOutlined, CheckCircleOutlined, SettingOutlined, BulbOutlined } from '@ant-design/icons'
|
||||
import dayjs from 'dayjs'
|
||||
import { getDepartments, suggestFields } from './services'
|
||||
import {
|
||||
PHASE_OPTIONS, SUB_PROJECT_OPTIONS, CATEGORY_OPTIONS, IMPACT_LEVEL_OPTIONS,
|
||||
USERS, PRIORITY_LABELS, STATUS_LABELS, STATUS_COLORS
|
||||
} from '../../constants/issue'
|
||||
|
||||
export interface IssueFormValues {
|
||||
title?: string
|
||||
description?: string
|
||||
phase?: string
|
||||
subProject?: string
|
||||
category?: string
|
||||
impactLevel?: string
|
||||
impactScope?: string
|
||||
deployment?: string
|
||||
pgmNo?: string
|
||||
reviewWorkload?: number
|
||||
responseWorkload?: number
|
||||
responseContent?: string
|
||||
ngReason?: string
|
||||
assigneeId?: number
|
||||
reviewerId?: number
|
||||
validatorId?: number
|
||||
priority?: string
|
||||
status?: string
|
||||
departmentId?: number
|
||||
deadline?: string
|
||||
responseCompletedAt?: string
|
||||
confirmAt?: string
|
||||
}
|
||||
|
||||
const userOptions = USERS.map(u => ({ label: u.name, value: u.id }))
|
||||
const priorityOptions = Object.entries(PRIORITY_LABELS).map(([v, l]) => ({ label: l, value: v }))
|
||||
|
||||
interface Props {
|
||||
initialValues?: Partial<IssueFormValues>
|
||||
submitting?: boolean
|
||||
mode?: 'create' | 'edit'
|
||||
onSubmit: (values: IssueFormValues) => void
|
||||
}
|
||||
|
||||
const label = { display: 'block', fontSize: 11, color: '#999', marginBottom: 4, fontWeight: 600 } as const
|
||||
|
||||
export default forwardRef(function IssueForm({ initialValues, submitting, mode = 'create', onSubmit }: Props, ref) {
|
||||
const [form] = Form.useForm()
|
||||
const status = initialValues?.status
|
||||
const [deptOptions, setDeptOptions] = useState<{ label: string; value: number }[]>([])
|
||||
const [agentLoading, setAgentLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getDepartments().then((res: any) => {
|
||||
setDeptOptions((res.data || []).map((d: any) => ({ label: d.name, value: d.id })))
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
form
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(toForm(initialValues || {}))
|
||||
}, [initialValues, form])
|
||||
|
||||
const toForm = (v: Partial<IssueFormValues>) => ({
|
||||
...v,
|
||||
deadline: v.deadline ? dayjs(v.deadline) : undefined,
|
||||
responseCompletedAt: v.responseCompletedAt ? dayjs(v.responseCompletedAt) : undefined,
|
||||
confirmAt: v.confirmAt ? dayjs(v.confirmAt) : undefined
|
||||
})
|
||||
|
||||
const handleFinish = (values: any) => {
|
||||
onSubmit({
|
||||
...values,
|
||||
deadline: values.deadline ? values.deadline.format('YYYY-MM-DDTHH:mm:ss') : undefined,
|
||||
responseCompletedAt: values.responseCompletedAt ? values.responseCompletedAt.format('YYYY-MM-DDTHH:mm:ss') : undefined,
|
||||
confirmAt: values.confirmAt ? values.confirmAt.format('YYYY-MM-DDTHH:mm:ss') : undefined
|
||||
})
|
||||
}
|
||||
|
||||
const fallbackFill = (title?: string) => {
|
||||
let category = '功能缺陷'
|
||||
if (title && /(UI|界面|布局|样式|适配)/i.test(title)) category = 'UI/UX问题'
|
||||
if (title && /(性能|慢|超时|卡顿)/i.test(title)) category = '性能问题'
|
||||
if (title && /(安全|漏洞|权限)/i.test(title)) category = '安全漏洞'
|
||||
form.setFieldsValue({ category, phase: '设计', impactLevel: '中', priority: 'medium' })
|
||||
}
|
||||
|
||||
const agentFill = async () => {
|
||||
const title = form.getFieldValue('title') as string | undefined
|
||||
const description = form.getFieldValue('description') as string | undefined
|
||||
if (!title) {
|
||||
message.warning('请先填写指摘标题')
|
||||
return
|
||||
}
|
||||
setAgentLoading(true)
|
||||
try {
|
||||
const res: any = await suggestFields(title, description)
|
||||
const fields = res?.data || {}
|
||||
if (Object.keys(fields).length > 0) {
|
||||
if (fields.deadline) {
|
||||
fields.deadline = dayjs(fields.deadline)
|
||||
}
|
||||
form.setFieldsValue(fields)
|
||||
message.success('Agent 已智能填充字段')
|
||||
} else {
|
||||
fallbackFill(title)
|
||||
message.warning('Agent 未识别到有效建议,已按标题规则填充')
|
||||
}
|
||||
} catch {
|
||||
fallbackFill(title)
|
||||
message.warning('Agent 分析超时或失败,已按标题规则填充')
|
||||
} finally {
|
||||
setAgentLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field = (name: keyof IssueFormValues, child: React.ReactNode, required = false) => (
|
||||
<Form.Item name={name} rules={required ? [{ required: true, message: '必填' }] : undefined}>
|
||||
{child}
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
const section = (icon: React.ReactNode, title: string, children: React.ReactNode, style?: React.CSSProperties) => (
|
||||
<Card style={{ marginBottom: 16, ...style }} title={<Typography.Text strong>{icon}{title}</Typography.Text>}>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={toForm(initialValues || {})}
|
||||
onFinish={handleFinish}
|
||||
onFinishFailed={() => message.warning('请填写必填项,请检查红色标记字段')}
|
||||
scrollToFirstError={{ behavior: 'smooth', block: 'center' }}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
{section(
|
||||
<FileTextOutlined style={{ marginRight: 8, color: '#1677ff' }} />, '基本信息',
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<span style={label}>指摘标题 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('title', <Input placeholder="请输入指摘标题" />, true)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>工程阶段 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('phase', <Select placeholder="请选择" options={PHASE_OPTIONS.map(o => ({ label: o, value: o }))} />, true)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>子工程 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('subProject', <Select placeholder="请选择" options={SUB_PROJECT_OPTIONS.map(o => ({ label: o, value: o }))} />, true)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>区分 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('category', <Select placeholder="请选择" options={CATEGORY_OPTIONS.map(o => ({ label: o, value: o }))} />, true)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>关联 PGM</span>
|
||||
{field('pgmNo', <Input placeholder="如 PGM_AUTH_VIEW_001" />)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>影响度 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('impactLevel', <Select placeholder="请选择" options={IMPACT_LEVEL_OPTIONS.map(o => ({ label: o, value: o }))} />, true)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>影响工程</span>
|
||||
{field('impactScope', <Input placeholder="如:前端适配、数据库查询" />)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>部署</span>
|
||||
{field('deployment', <Input placeholder="部署位置/环境" />)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>Review 者 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('reviewerId', <Select placeholder="请选择" options={userOptions} />, true)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>Review 工数 (h)</span>
|
||||
{field('reviewWorkload', <InputNumber min={0} step={0.5} style={{ width: '100%' }} placeholder="0.0" />)}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{section(
|
||||
<MessageOutlined style={{ marginRight: 8, color: '#fa8c16' }} />, '指摘内容',
|
||||
<>
|
||||
<span style={label}>指摘内容 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('description', <Input.TextArea rows={5} placeholder="请详细描述问题现象、复现步骤、期望结果..." />, true)}
|
||||
<span style={label}>NG 原因</span>
|
||||
{field('ngReason', <Input placeholder="如:设计遗漏、编码错误、需求变更" />)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{section(
|
||||
<CheckCircleOutlined style={{ marginRight: 8, color: '#52c41a' }} />, '对应信息',
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<span style={label}>对应者 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('assigneeId', <Select placeholder="请选择" options={userOptions} />, true)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>对应工数 (h)</span>
|
||||
{field('responseWorkload', <InputNumber min={0} step={0.5} style={{ width: '100%' }} placeholder="0.0" />)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<span style={label}>对应完了日</span>
|
||||
<Form.Item name="responseCompletedAt" rules={[{ validator: (_: any, v: dayjs.Dayjs) => {
|
||||
const deadline = form.getFieldValue('deadline')
|
||||
if (v && deadline && v.isBefore(deadline, 'day')) {
|
||||
return Promise.reject(new Error('对应完了日不能早于整改截止日期'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
} }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<span style={label}>对应内容</span>
|
||||
{field('responseContent', <Input.TextArea rows={3} placeholder="填写整改方案或对应措施..." />)}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<span style={label}>确认者</span>
|
||||
{field('validatorId', <Select allowClear placeholder="请选择" options={userOptions} />)}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<span style={label}>确认日</span>
|
||||
<Form.Item name="confirmAt" rules={[{ validator: (_: any, v: dayjs.Dayjs) => {
|
||||
const completed = form.getFieldValue('responseCompletedAt')
|
||||
if (v && completed && v.isBefore(completed, 'day')) {
|
||||
return Promise.reject(new Error('确认日不能早于对应完了日'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
} }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
{section(
|
||||
<SettingOutlined style={{ marginRight: 8, color: '#1677ff' }} />, '快速设置',
|
||||
<>
|
||||
<span style={label}>状态</span>
|
||||
{mode === 'edit' ? (
|
||||
<div>
|
||||
<Tag color={STATUS_COLORS[status ?? ''] || 'default'}>{status ? STATUS_LABELS[status] || status : '-'}</Tag>
|
||||
<div style={{ fontSize: 11, color: '#999', marginTop: 4 }}>状态变更请在详情页操作</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>草稿</div>
|
||||
)}
|
||||
<span style={label}>优先级 <span style={{ color: '#ff4d4f' }}>*</span></span>
|
||||
{field('priority', <Radio.Group options={priorityOptions} optionType="button" buttonStyle="solid" />, true)}
|
||||
<span style={label}>归属部门</span>
|
||||
{field('departmentId', <Select options={deptOptions} />)}
|
||||
<span style={label}>整改截止日期</span>
|
||||
{field('deadline', <DatePicker style={{ width: '100%' }} />)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card style={{ marginBottom: 16, background: '#1677ff', color: '#fff' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Typography.Text strong style={{ color: '#fff' }}><BulbOutlined style={{ marginRight: 8 }} />Agent 辅助</Typography.Text>
|
||||
<Typography.Text style={{ color: '#bae0ff', fontSize: 12 }}>输入标题后,Agent 可自动分析并推荐工程阶段、优先级、影响度等字段。</Typography.Text>
|
||||
<Button block loading={agentLoading} style={{ background: 'rgba(255,255,255,.2)', border: 'none', color: '#fff' }} onClick={agentFill}>
|
||||
<BulbOutlined /> 让 Agent 智能填充
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,504 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button, Tag, Space, Descriptions, Card, List, Popconfirm, Timeline, Modal, Select, Input,
|
||||
Typography, Empty, Collapse
|
||||
} from 'antd'
|
||||
import { message, modal } from '../../antdStatic'
|
||||
import {
|
||||
ArrowLeftOutlined, ArrowRightOutlined, EditOutlined, DownloadOutlined, DeleteOutlined, PaperClipOutlined,
|
||||
UploadOutlined, RobotOutlined, SendOutlined, CheckOutlined, CloseOutlined,
|
||||
EyeOutlined, CodeOutlined, CheckCircleOutlined, UserOutlined, CloseCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
getIssue, getIssueLogs, changeStatus, listAttachments, deleteAttachment, uploadAttachment,
|
||||
executeAgent, getAgentStatus, approveAgentPlan, rejectAgentPlan, changeAgentMode, downloadAttachment
|
||||
} from './services'
|
||||
import type { Issue, Attachment, IssueLogItem } from './services'
|
||||
import { STATUS_LABELS, STATUS_COLORS, PRIORITY_LABELS, PRIORITY_COLORS, ACTION_LABELS } from '../../constants/issue'
|
||||
|
||||
const NEXT_STATUS: Record<string, string[]> = {
|
||||
draft: ['open'],
|
||||
open: ['draft', 'in_progress', 'rejected'],
|
||||
in_progress: ['open', 'resolved', 'rejected'],
|
||||
resolved: ['in_progress', 'verified', 'rejected'],
|
||||
verified: ['resolved', 'in_progress', 'closed', 'rejected'],
|
||||
closed: [],
|
||||
rejected: ['open', 'closed']
|
||||
}
|
||||
|
||||
interface ToolCard {
|
||||
toolName: string
|
||||
inputParams: string
|
||||
outputResult: string
|
||||
}
|
||||
|
||||
export default function IssueDetailPage() {
|
||||
const { id } = useParams()
|
||||
const issueId = Number(id)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [issue, setIssue] = useState<Issue | null>(null)
|
||||
const [logs, setLogs] = useState<IssueLogItem[]>([])
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
|
||||
const [statusModalOpen, setStatusModalOpen] = useState(false)
|
||||
const [targetStatus, setTargetStatus] = useState<string>()
|
||||
const [statusRemark, setStatusRemark] = useState('')
|
||||
|
||||
const [goal, setGoal] = useState('')
|
||||
const [planId, setPlanId] = useState<number>()
|
||||
const [agentRunning, setAgentRunning] = useState(false)
|
||||
const [toolCards, setToolCards] = useState<ToolCard[]>([])
|
||||
const [awaitingApproval, setAwaitingApproval] = useState(false)
|
||||
const [approvalComment, setApprovalComment] = useState('')
|
||||
const [agentMsg, setAgentMsg] = useState<string>()
|
||||
const [agentError, setAgentError] = useState<string>()
|
||||
const [modelProvider, setModelProvider] = useState<string>()
|
||||
const [promptTpl, setPromptTpl] = useState<{ systemId?: string; systemVer?: string; planId?: string; planVer?: string }>({})
|
||||
const [previewUrl, setPreviewUrl] = useState<string>()
|
||||
const [previewName, setPreviewName] = useState('')
|
||||
|
||||
const load = async () => {
|
||||
const res: any = await getIssue(issueId)
|
||||
setIssue(res.data)
|
||||
}
|
||||
|
||||
const loadLogs = async () => {
|
||||
const res: any = await getIssueLogs(issueId)
|
||||
setLogs(res.data || [])
|
||||
}
|
||||
|
||||
const loadAttachments = async () => {
|
||||
const res: any = await listAttachments(issueId)
|
||||
setAttachments(res.data || [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(); loadLogs(); loadAttachments()
|
||||
}, [issueId])
|
||||
|
||||
useEffect(() => {
|
||||
if (issue?.agentStatus === 'failed' && issue.agentLastPlanId) {
|
||||
getAgentStatus(issue.agentLastPlanId).then((res: any) => {
|
||||
const plan = res.data
|
||||
setAgentMsg(undefined)
|
||||
setAgentError(plan?.agentMessage || 'Agent 执行失败,未返回失败原因,请查看后端日志。')
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [issue?.agentStatus, issue?.agentLastPlanId])
|
||||
|
||||
const handleChangeStatus = async () => {
|
||||
if (!issue) return
|
||||
if (!targetStatus) return message.warning('请选择目标状态')
|
||||
if (targetStatus === 'rejected' && !statusRemark.trim()) return message.warning('请填写驳回原因')
|
||||
const doChange = async () => {
|
||||
try {
|
||||
await changeStatus(issueId, targetStatus, statusRemark.trim() || undefined)
|
||||
message.success('状态已更新')
|
||||
setStatusModalOpen(false)
|
||||
setTargetStatus(undefined); setStatusRemark('')
|
||||
load(); loadLogs()
|
||||
} catch { message.error('状态变更失败') }
|
||||
}
|
||||
if (targetStatus === 'closed') {
|
||||
const missing = []
|
||||
if (!issue.responseContent) missing.push('对应内容')
|
||||
if (!issue.responseCompletedAt) missing.push('对应完了日')
|
||||
if (!issue.confirmAt) missing.push('确认日')
|
||||
if (missing.length) message.warning(`关闭前建议补充:${missing.join('、')}(可继续操作)`)
|
||||
modal.confirm({
|
||||
title: '确认关闭指摘?',
|
||||
content: '关闭后该指摘进入终态,不可恢复,请确认整改与验证已完成。',
|
||||
okText: '确认关闭',
|
||||
onOk: doChange
|
||||
})
|
||||
} else {
|
||||
await doChange()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
await uploadAttachment(issueId, file)
|
||||
message.success('上传成功')
|
||||
loadAttachments()
|
||||
return false
|
||||
}
|
||||
|
||||
const handleDownload = async (att: Attachment) => {
|
||||
const blob = await downloadAttachment(issueId, att.id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = att.fileName; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleDeleteAtt = async (attId: number) => {
|
||||
await deleteAttachment(issueId, attId)
|
||||
message.success('已删除')
|
||||
loadAttachments()
|
||||
}
|
||||
|
||||
const handlePreview = async (att: Attachment) => {
|
||||
const blob = await downloadAttachment(issueId, att.id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
setPreviewName(att.fileName)
|
||||
setPreviewUrl(url)
|
||||
}
|
||||
|
||||
const handleAgentExecute = async () => {
|
||||
if (!goal.trim()) return message.warning('请输入指令')
|
||||
setAgentRunning(true); setToolCards([]); setAwaitingApproval(false); setAgentMsg(undefined); setAgentError(undefined)
|
||||
try {
|
||||
const res: any = await executeAgent(issueId, goal)
|
||||
const data = res.data
|
||||
setPlanId(data.planId)
|
||||
setGoal('')
|
||||
setAgentMsg('指令已解析并执行,正在生成对应方案...')
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const es = new EventSource(`/api/v1/agent/plan/${data.planId}/stream?token=${token}`)
|
||||
es.onmessage = (event) => {
|
||||
let ev: any = {}
|
||||
try { ev = JSON.parse(event.data) } catch { return }
|
||||
switch (ev.type) {
|
||||
case 'thought':
|
||||
setAgentMsg(ev.content)
|
||||
break
|
||||
case 'action':
|
||||
setToolCards(prev => [...prev, {
|
||||
toolName: ev.tool,
|
||||
inputParams: ev.params ? JSON.stringify(ev.params) : '',
|
||||
outputResult: ev.approval ? '等待人工审批' : ''
|
||||
}])
|
||||
if (ev.approval) setAwaitingApproval(true)
|
||||
break
|
||||
case 'observation':
|
||||
setToolCards(prev => {
|
||||
if (!prev.length) return prev
|
||||
const copy = [...prev]
|
||||
copy[copy.length - 1] = { ...copy[copy.length - 1], outputResult: ev.result || '' }
|
||||
return copy
|
||||
})
|
||||
break
|
||||
case 'result':
|
||||
setAgentMsg(ev.content)
|
||||
setAgentRunning(false)
|
||||
break
|
||||
case 'prompt_info':
|
||||
setPromptTpl(prev => ev.templateId === 'SYS_ROLE_001'
|
||||
? { ...prev, systemId: ev.templateId, systemVer: ev.version }
|
||||
: { ...prev, planId: ev.templateId, planVer: ev.version })
|
||||
break
|
||||
case 'model_info':
|
||||
setModelProvider(ev.provider)
|
||||
break
|
||||
case 'error':
|
||||
setAgentError(ev.message || 'Agent 执行失败')
|
||||
setAgentMsg(undefined)
|
||||
setAgentRunning(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
setAgentRunning(false)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!planId) return
|
||||
await approveAgentPlan(planId, approvalComment)
|
||||
message.success('已批准执行')
|
||||
setAwaitingApproval(false)
|
||||
setAgentMsg('Agent 指令已批准,对应方案已推送至担当者。')
|
||||
load()
|
||||
}
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!planId) return
|
||||
await rejectAgentPlan(planId, approvalComment)
|
||||
message.success('已驳回')
|
||||
setAwaitingApproval(false)
|
||||
setAgentMsg('Agent 指令已驳回,已切换回人工模式。')
|
||||
load()
|
||||
}
|
||||
|
||||
const handleSwitchToHuman = async () => {
|
||||
await changeAgentMode(issueId, 'human_driven')
|
||||
message.success('已切换至纯人工模式')
|
||||
load()
|
||||
}
|
||||
|
||||
if (!issue) return <div style={{ padding: 48, textAlign: 'center' }}><Empty /></div>
|
||||
|
||||
const agentStatusTag = (() => {
|
||||
const s = issue.agentStatus
|
||||
if (s === 'awaiting_approval') return <Tag color="orange">待审批</Tag>
|
||||
if (s === 'human_driven') return <Tag color="default">人工模式</Tag>
|
||||
if (s === 'running') return <Tag color="green">分析中</Tag>
|
||||
if (s === 'completed') return <Tag color="green">已完成</Tag>
|
||||
if (s === 'rejected') return <Tag color="red">已驳回</Tag>
|
||||
if (s === 'failed') return <Tag color="red">失败</Tag>
|
||||
return <Tag color="default">就绪</Tag>
|
||||
})()
|
||||
|
||||
const statusCard = (label: string, value: React.ReactNode) => (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#999', marginBottom: 4, fontWeight: 600 }}>{label}</div>
|
||||
<div style={{ fontWeight: 600 }}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/issues')} />
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#1677ff', fontWeight: 600 }}>{issue.issueNo}</div>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>{issue.title}</Typography.Title>
|
||||
</div>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button icon={<EditOutlined />} onClick={() => navigate(`/issues/${issueId}/edit`)}>编辑</Button>
|
||||
<Button type="primary" disabled={NEXT_STATUS[issue.status]?.length === 0} onClick={() => setStatusModalOpen(true)}>更改状态</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
|
||||
<Card style={{ flex: 1.5, marginBottom: 16 }}>
|
||||
<Space size={32} style={{ marginBottom: 16 }}>
|
||||
{statusCard('当前状态', <Tag color={STATUS_COLORS[issue.status]}>{STATUS_LABELS[issue.status] || issue.status}</Tag>)}
|
||||
{statusCard('优先级', <Tag color={PRIORITY_COLORS[issue.priority]}>{PRIORITY_LABELS[issue.priority] || issue.priority}</Tag>)}
|
||||
{statusCard('对应者', issue.assigneeName || '未分配')}
|
||||
{statusCard('创建日期', (issue.createdAt || '').slice(0, 10))}
|
||||
</Space>
|
||||
|
||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="工程阶段">{issue.phase || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="子工程">{issue.subProject || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="区分">{issue.category || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="影响度">{issue.impactLevel || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联 PGM">{issue.pgmNo || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="影响工程">{issue.impactScope || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="部署">{issue.deployment || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="整改截止">{issue.deadline ? issue.deadline.slice(0, 10) : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建人">{issue.creatorName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Review 者">{issue.reviewerName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Review 工数">{issue.reviewWorkload != null ? `${issue.reviewWorkload} h` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="对应完了日">{issue.responseCompletedAt ? issue.responseCompletedAt.slice(0, 10) : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="确认者">{issue.validatorName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="确认日">{issue.confirmAt ? issue.confirmAt.slice(0, 10) : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="NG 原因">{issue.ngReason || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="对应内容">{issue.responseContent || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 600, marginBottom: 8 }}>指摘内容</div>
|
||||
<div style={{ background: '#fafafa', borderRadius: 8, padding: 16, fontSize: 13, lineHeight: 1.8 }}>
|
||||
{issue.description || '暂无内容'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 11, color: '#999', fontWeight: 600 }}>附件 ({attachments.length})</span>
|
||||
<label style={{ cursor: 'pointer', color: '#1677ff', fontSize: 12 }}>
|
||||
<UploadOutlined style={{ marginRight: 4 }} />
|
||||
添加附件
|
||||
<input
|
||||
type="file" multiple style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
files.forEach(handleUpload)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<List
|
||||
size="small" dataSource={attachments} locale={{ emptyText: '暂无附件' }}
|
||||
renderItem={(att) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button key="p" type="text" size="small" icon={<EyeOutlined />} onClick={() => handlePreview(att)} />,
|
||||
<Button key="d" type="text" size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(att)} />,
|
||||
<Popconfirm key="del" title="确认删除?" onConfirm={() => handleDeleteAtt(att.id)}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta avatar={<PaperClipOutlined />} title={att.fileName}
|
||||
description={`${(att.fileSize / 1024).toFixed(1)} KB · ${att.uploadedByName || ''}`} />
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 600, marginBottom: 8 }}>状态流转历史</div>
|
||||
<Collapse
|
||||
ghost defaultActiveKey={['history']}
|
||||
items={[{
|
||||
key: 'history',
|
||||
label: <span style={{ fontSize: 12, color: '#666' }}>展开 / 折叠流转记录(审计参考)</span>,
|
||||
children: (
|
||||
<Timeline
|
||||
items={logs.map((l, i) => ({
|
||||
key: i,
|
||||
color: i === 0 ? '#1677ff' : 'gray',
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{ACTION_LABELS[l.action] || l.action}
|
||||
{l.fromStatus && l.toStatus && <Tag style={{ marginLeft: 8 }} color="blue">{STATUS_LABELS[l.fromStatus]} → {STATUS_LABELS[l.toStatus]}</Tag>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginTop: 4 }}>
|
||||
{l.userName} · {l.remark || ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#bbb' }}>{l.createdAt?.replace('T', ' ').slice(0, 16)}</div>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{ flex: 1, marginBottom: 16, background: '#fcfdff', position: 'sticky', top: 24 }}
|
||||
title={
|
||||
<Space>
|
||||
<RobotOutlined style={{ color: '#1677ff' }} />
|
||||
<span>IMS Agent 驾驶舱</span>
|
||||
{agentStatusTag}
|
||||
{modelProvider && <Tag color="blue">引擎:{modelProvider}</Tag>}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{ background: '#e6f4ff', borderRadius: 8, padding: 12, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
你好,我是您的指摘处理助手。我可以帮您 <b>催办担当者</b>、<b>查找知识库相似案例</b> 或 <b>自动生成对应方案</b>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issue.agentStatus && issue.agentStatus !== 'human_driven' && (
|
||||
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||||
<Button size="small" icon={<UserOutlined />} onClick={handleSwitchToHuman}>转人工</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolCards.map((t, i) => (
|
||||
<div key={i} style={{ borderLeft: '3px solid #fa8c16', background: '#fffbe6', borderRadius: 8, padding: 10, marginBottom: 8 }}>
|
||||
<Space style={{ marginBottom: 6 }}>
|
||||
<CodeOutlined style={{ color: '#fa8c16' }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 12, color: '#d46b08' }}>行动</span>
|
||||
</Space>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 11, background: 'rgba(250,173,20,.1)', borderRadius: 6, padding: 8, wordBreak: 'break-all' }}>
|
||||
call: <b>{t.toolName}</b>({t.inputParams})
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginTop: 6 }}><EyeOutlined style={{ marginRight: 4 }} />{t.outputResult}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{awaitingApproval && (
|
||||
<div style={{ border: '2px solid #ff4d4f', borderRadius: 8, padding: 12, marginBottom: 8, background: '#fff1f0' }}>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||
<span style={{ fontWeight: 600, color: '#cf1322' }}>需要您的审批</span>
|
||||
</Space>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||
Agent 准备执行上述方案。是否批准?
|
||||
</div>
|
||||
<Input.TextArea rows={2} placeholder="审批意见(可选)" value={approvalComment}
|
||||
onChange={e => setApprovalComment(e.target.value)} style={{ marginBottom: 8, fontSize: 12 }} />
|
||||
<Space>
|
||||
<Button size="small" type="primary" danger icon={<CheckOutlined />} onClick={handleApprove}>批准执行</Button>
|
||||
<Button size="small" icon={<CloseOutlined />} onClick={handleReject}>驳回</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentError && !awaitingApproval && (
|
||||
<div style={{ background: '#fff1f0', border: '1px solid #ffa39e', borderRadius: 8, padding: 10, marginBottom: 8, fontSize: 12, color: '#cf1322' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}><CloseCircleOutlined style={{ marginRight: 4 }} />Agent 执行失败</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{agentError}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentMsg && !awaitingApproval && (
|
||||
<div style={{ background: '#f6ffed', borderRadius: 8, padding: 10, marginBottom: 8, fontSize: 12, color: '#389e0d' }}>
|
||||
{agentMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
style={{ marginBottom: 8 }}
|
||||
items={[{
|
||||
key: 'prompt',
|
||||
label: <span style={{ fontSize: 12, color: '#999' }}>Prompt 模板信息</span>,
|
||||
children: (
|
||||
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
|
||||
<div>系统角色:<b>{promptTpl.systemId || '未提供'}</b>({promptTpl.systemVer ? `v${promptTpl.systemVer}` : '-'})</div>
|
||||
<div>规划模板:<b>{promptTpl.planId || '未提供'}</b>({promptTpl.planVer ? `v${promptTpl.planVer}` : '-'})</div>
|
||||
<div style={{ color: '#999', marginTop: 4 }}>模板由 Agent 引擎从 Prompt 模板库动态加载,此处为当前执行使用的模板标识。</div>
|
||||
</div>
|
||||
)
|
||||
}]}
|
||||
/>
|
||||
|
||||
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 8 }}>
|
||||
<Input.TextArea rows={2} placeholder="给 Agent 下达指令,如:查找知识库相似案例,并生成对应方案" value={goal} onChange={e => setGoal(e.target.value)} />
|
||||
<Button type="primary" block style={{ marginTop: 8 }} icon={<SendOutlined />} loading={agentRunning} onClick={handleAgentExecute}>
|
||||
{agentRunning ? '执行中...' : '执行指令'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal title="更改状态" open={statusModalOpen} onOk={handleChangeStatus} onCancel={() => setStatusModalOpen(false)}
|
||||
okText={targetStatus === 'rejected' ? '确认驳回' : '确认变更'}>
|
||||
<div style={{ marginBottom: 12, fontSize: 12, color: '#999' }}>流转路径</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Tag color={STATUS_COLORS[issue.status]} style={{ fontWeight: 600, fontSize: 13 }}>{STATUS_LABELS[issue.status]}</Tag>
|
||||
<ArrowRightOutlined style={{ color: '#999' }} />
|
||||
<Select
|
||||
style={{ flex: 1 }} placeholder="选择目标状态" value={targetStatus}
|
||||
onChange={setTargetStatus}
|
||||
options={(NEXT_STATUS[issue.status] || []).map(s => ({ label: STATUS_LABELS[s] || s, value: s }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12, fontSize: 12, color: '#666' }}>
|
||||
当前状态可流转到:{(NEXT_STATUS[issue.status] || []).map(s => STATUS_LABELS[s] || s).join('、') || '无'}
|
||||
</div>
|
||||
{issue.status === 'verified' && (
|
||||
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#fff7e6', border: '1px solid #ffd591', borderRadius: 6, fontSize: 12, color: '#d46b08' }}>
|
||||
修改已验证状态的指摘属高风险操作(设计文档审批强制),请确认整改结果已核验。
|
||||
</div>
|
||||
)}
|
||||
{targetStatus === 'closed' && (
|
||||
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#fff1f0', border: '1px solid #ffa39e', borderRadius: 6, fontSize: 12, color: '#cf1322' }}>
|
||||
关闭为终态操作,不可恢复,提交时需二次确认。
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
||||
{targetStatus === 'rejected' ? (<span>驳回原因 <span style={{ color: '#ff4d4f' }}>*</span></span>) : '备注(可选)'}
|
||||
</div>
|
||||
<Input.TextArea rows={2}
|
||||
placeholder={targetStatus === 'rejected' ? '请填写驳回原因,将记录到流转历史' : '流转说明(可选)'}
|
||||
value={statusRemark} onChange={e => setStatusRemark(e.target.value)} />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal title={`预览:${previewName}`} open={!!previewUrl} footer={null} width={720}
|
||||
onCancel={() => { setPreviewUrl(undefined); setPreviewName('') }}>
|
||||
{previewUrl && <iframe src={previewUrl} title={previewName} style={{ width: '100%', height: 480, border: 'none' }} />}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Typography, Button, Space, Upload, Card, List, Popconfirm, Modal, Timeline, Tag } from 'antd'
|
||||
import { message } from '../../antdStatic'
|
||||
import { ArrowLeftOutlined, SaveOutlined, UploadOutlined, DownloadOutlined, DeleteOutlined, ReloadOutlined, HistoryOutlined, RobotOutlined } from '@ant-design/icons'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import IssueForm from './IssueForm'
|
||||
import type { IssueFormValues } from './IssueForm'
|
||||
import { getIssue, updateIssue, deleteIssue, listAttachments, uploadAttachment, deleteAttachment, getIssueLogs, suggestIssue, downloadAttachment } from './services'
|
||||
import type { Attachment, IssueLogItem } from './services'
|
||||
import { ACTION_LABELS, STATUS_LABELS } from '../../constants/issue'
|
||||
|
||||
export default function IssueEditPage() {
|
||||
const { id } = useParams()
|
||||
const issueId = Number(id)
|
||||
const navigate = useNavigate()
|
||||
const formRef = useRef<{ form: any }>(null)
|
||||
const [initialValues, setInitialValues] = useState<Partial<IssueFormValues>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [logs, setLogs] = useState<IssueLogItem[]>([])
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [suggestOpen, setSuggestOpen] = useState(false)
|
||||
const [suggestText, setSuggestText] = useState('')
|
||||
const [suggesting, setSuggesting] = useState(false)
|
||||
|
||||
const loadIssue = async () => {
|
||||
const res: any = await getIssue(issueId)
|
||||
const d = res.data
|
||||
setInitialValues({
|
||||
title: d.title, description: d.description, phase: d.phase, subProject: d.subProject,
|
||||
category: d.category, impactLevel: d.impactLevel, impactScope: d.impactScope,
|
||||
deployment: d.deployment, pgmNo: d.pgmNo, reviewWorkload: d.reviewWorkload,
|
||||
responseWorkload: d.responseWorkload, responseContent: d.responseContent, ngReason: d.ngReason,
|
||||
assigneeId: d.assigneeId, reviewerId: d.reviewerId, validatorId: d.validatorId,
|
||||
priority: d.priority, status: d.status, departmentId: d.departmentId,
|
||||
deadline: d.deadline, responseCompletedAt: d.responseCompletedAt, confirmAt: d.confirmAt
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await loadIssue()
|
||||
loadAttachments()
|
||||
})()
|
||||
}, [issueId])
|
||||
|
||||
const loadAttachments = async () => {
|
||||
const res: any = await listAttachments(issueId)
|
||||
setAttachments(res.data || [])
|
||||
}
|
||||
|
||||
const handleSave = () => formRef.current?.form.submit()
|
||||
|
||||
const handleReset = async () => {
|
||||
await loadIssue()
|
||||
message.success('已恢复初始数据')
|
||||
}
|
||||
|
||||
const loadLogs = async () => {
|
||||
const res: any = await getIssueLogs(issueId)
|
||||
setLogs(res.data || [])
|
||||
}
|
||||
|
||||
const openHistory = () => {
|
||||
loadLogs()
|
||||
setHistoryOpen(true)
|
||||
}
|
||||
|
||||
const handleSuggest = async () => {
|
||||
setSuggesting(true)
|
||||
setSuggestText('')
|
||||
setSuggestOpen(true)
|
||||
try {
|
||||
const res: any = await suggestIssue(issueId, '请基于当前指摘内容给出字段修改建议')
|
||||
setSuggestText(res.data || '')
|
||||
} catch { /* 错误由拦截器提示 */ }
|
||||
setSuggesting(false)
|
||||
}
|
||||
|
||||
const handleDeleteIssue = async () => {
|
||||
await deleteIssue(issueId)
|
||||
message.success('已删除指摘')
|
||||
navigate('/issues')
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: IssueFormValues) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateIssue(issueId, values as Record<string, unknown>)
|
||||
message.success('保存成功')
|
||||
navigate(`/issues/${issueId}`)
|
||||
} catch { /* ignore */ }
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
await uploadAttachment(issueId, file)
|
||||
message.success('上传成功')
|
||||
loadAttachments()
|
||||
return false
|
||||
}
|
||||
|
||||
const handleDownload = async (att: Attachment) => {
|
||||
const blob = await downloadAttachment(issueId, att.id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = att.fileName; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleDelete = async (attId: number) => {
|
||||
await deleteAttachment(issueId, attId)
|
||||
message.success('已删除')
|
||||
loadAttachments()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate(`/issues/${issueId}`)} />
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>编辑指摘</Typography.Title>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>恢复初始</Button>
|
||||
<Button icon={<HistoryOutlined />} onClick={openHistory}>修改历史</Button>
|
||||
<Button icon={<RobotOutlined />} loading={suggesting} onClick={handleSuggest}>Agent 建议修改</Button>
|
||||
<Popconfirm title="确认删除该指摘?" onConfirm={handleDeleteIssue} okText="删除" okButtonProps={{ danger: true }}>
|
||||
<Button danger icon={<DeleteOutlined />}>删除指摘</Button>
|
||||
</Popconfirm>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={submitting} onClick={handleSave}>保存指摘</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<IssueForm ref={formRef} mode="edit" initialValues={initialValues} submitting={submitting} onSubmit={handleSubmit} />
|
||||
|
||||
<Card style={{ maxWidth: 1280, margin: '0 auto 24px' }} title={`附件 (${attachments.length})`}>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.log,.xlsx,.xls"
|
||||
beforeUpload={(file) => { handleUpload(file); return false }}
|
||||
showUploadList={false}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><UploadOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||
</Upload.Dragger>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={attachments}
|
||||
locale={{ emptyText: '暂无附件' }}
|
||||
renderItem={(att) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button key="d" type="text" size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(att)} />,
|
||||
<Popconfirm key="del" title="确认删除?" onConfirm={() => handleDelete(att.id)}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta title={att.fileName} description={`${(att.fileSize / 1024).toFixed(1)} KB`} />
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal title="修改历史" open={historyOpen} footer={null} width={640} onCancel={() => setHistoryOpen(false)}>
|
||||
<Timeline
|
||||
items={logs.map((l, i) => ({
|
||||
key: i,
|
||||
color: i === 0 ? '#1677ff' : 'gray',
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{ACTION_LABELS[l.action] || l.action}
|
||||
{l.fromStatus && l.toStatus && <Tag style={{ marginLeft: 8 }} color="blue">{STATUS_LABELS[l.fromStatus]} → {STATUS_LABELS[l.toStatus]}</Tag>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginTop: 4 }}>{l.userName} · {l.remark || ''}</div>
|
||||
<div style={{ fontSize: 11, color: '#bbb' }}>{l.createdAt?.replace('T', ' ').slice(0, 16)}</div>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="Agent 建议修改" open={suggestOpen} footer={null} width={640}
|
||||
onCancel={() => setSuggestOpen(false)}
|
||||
>
|
||||
<div style={{ maxHeight: 420, overflow: 'auto', whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.8 }}>
|
||||
{suggesting ? <Typography.Text type="secondary">Agent 分析中...</Typography.Text> : (suggestText || '暂未生成建议')}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Table, Button, Input, Select, Space, Tag, Card, Popconfirm, Row, Col, Statistic, Typography, Modal, DatePicker
|
||||
} from 'antd'
|
||||
import { message } from '../../antdStatic'
|
||||
import { PlusOutlined, SearchOutlined, DownloadOutlined, RobotOutlined, ReloadOutlined, OrderedListOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
listIssues, deleteIssue, exportIssues, batchAssign, batchNotify, batchAgent, getDepartments
|
||||
} from './services'
|
||||
import { getDashboardStats } from '../dashboard/services'
|
||||
import {
|
||||
STATUS_LABELS, STATUS_COLORS, PRIORITY_LABELS, PRIORITY_COLORS,
|
||||
PHASE_OPTIONS, SUB_PROJECT_OPTIONS, IMPACT_LEVEL_OPTIONS, USERS
|
||||
} from '../../constants/issue'
|
||||
|
||||
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ label, value }))
|
||||
import type { Issue } from './services'
|
||||
|
||||
const IMPACT_ORDER: Record<string, number> = { 低: 1, 中: 2, 高: 3 }
|
||||
const STATUS_CARD_ORDER = ['open', 'in_progress', 'resolved', 'verified', 'closed'] as const
|
||||
const STATUS_CARD_COLORS: Record<string, string> = {
|
||||
open: '#fa8c16', in_progress: '#1677ff', resolved: '#13c2c2', verified: '#722ed1', closed: '#52c41a'
|
||||
}
|
||||
|
||||
export default function IssueListPage() {
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<Issue[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([])
|
||||
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [filters, setFilters] = useState<Record<string, string | undefined>>({
|
||||
phase: undefined, subProject: undefined, priority: undefined, impactLevel: undefined, status: undefined,
|
||||
assigneeId: undefined, departmentId: undefined, startDate: undefined, endDate: undefined
|
||||
})
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null)
|
||||
const [summary, setSummary] = useState<Record<string, number>>({})
|
||||
const [assignModalOpen, setAssignModalOpen] = useState(false)
|
||||
const [assigneeId, setAssigneeId] = useState<number>()
|
||||
const [agentModalOpen, setAgentModalOpen] = useState(false)
|
||||
const [batchGoal, setBatchGoal] = useState('')
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const assigneeOptions = USERS.map(u => ({ label: u.name, value: String(u.id) }))
|
||||
const [deptOptions, setDeptOptions] = useState<{ label: string; value: string }[]>([])
|
||||
|
||||
const load = async (p = page, extra = filters) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await listIssues({
|
||||
...extra, keyword: keyword || undefined, page: p, pageSize: 10
|
||||
})
|
||||
setData(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const res: any = await getDashboardStats()
|
||||
const dist = res.data?.statusDistribution || []
|
||||
setSummary(Object.fromEntries(dist.map((d: any) => [d.status, d.count])))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => { load(); loadSummary() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
getDepartments().then((res: any) => {
|
||||
setDeptOptions((res.data || []).map((d: any) => ({ label: d.name, value: String(d.id) })))
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSearch = () => load(1)
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({ phase: undefined, subProject: undefined, priority: undefined, impactLevel: undefined, status: undefined, assigneeId: undefined, departmentId: undefined, startDate: undefined, endDate: undefined })
|
||||
setKeyword('')
|
||||
setDateRange(null)
|
||||
load(1, {})
|
||||
}
|
||||
|
||||
const clickStatusCard = (status: string) => {
|
||||
const next = { ...filters, status: filters.status === status ? undefined : status }
|
||||
setFilters(next)
|
||||
load(1, next)
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await exportIssues({
|
||||
...filters, keyword: keyword || undefined
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'issues.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
message.success('导出成功')
|
||||
} catch {
|
||||
message.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchAssign = async () => {
|
||||
if (!assigneeId) return
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const res: any = await batchAssign(selectedKeys as number[], assigneeId)
|
||||
message.success(`已为 ${res.data?.count ?? 0} 条指摘统一分配担当者`)
|
||||
setAssignModalOpen(false)
|
||||
setAssigneeId(undefined)
|
||||
load()
|
||||
} catch { /* ignore */ }
|
||||
setBatchLoading(false)
|
||||
}
|
||||
|
||||
const handleBatchNotify = async () => {
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const res: any = await batchNotify(selectedKeys as number[], '您的指摘已逾期或即将到期,请尽快处理。')
|
||||
message.success(`已向 ${res.data?.count ?? 0} 条指摘的担当者发送催办`)
|
||||
load()
|
||||
} catch { /* ignore */ }
|
||||
setBatchLoading(false)
|
||||
}
|
||||
|
||||
const handleBatchAgent = async () => {
|
||||
const goal = batchGoal.trim()
|
||||
if (!goal) return message.warning('请输入 Agent 指令')
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const res: any = await batchAgent(selectedKeys as number[], goal)
|
||||
message.success(`Agent 已对 ${res.data?.count ?? 0} 条指摘生成方案,请到详情页审批`)
|
||||
setAgentModalOpen(false)
|
||||
setBatchGoal('')
|
||||
load()
|
||||
} catch { /* ignore */ }
|
||||
setBatchLoading(false)
|
||||
}
|
||||
|
||||
const tipOf = (r: Issue) => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
if (r.status === 'closed') return null
|
||||
if (r.deadline && r.deadline.slice(0, 10) < today) return { text: '已逾期,请尽快处理', color: '#ff4d4f' }
|
||||
if (!r.assigneeName) return { text: '未分配对应者', color: '#fa8c16' }
|
||||
if (r.status === 'verified') return { text: '已验证,待关闭', color: '#52c41a' }
|
||||
return null
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID / 标题', key: 'title', width: 300, render: (_: any, r: Issue) => (
|
||||
<div style={{ cursor: 'pointer' }} onClick={() => navigate(`/issues/${r.id}`)}>
|
||||
<div style={{ fontSize: 11, color: '#1677ff', fontWeight: 600 }}>{r.issueNo}</div>
|
||||
<div style={{ fontWeight: 500 }}>{r.title}</div>
|
||||
</div>
|
||||
) },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, align: 'center' as const, render: (s: string) => <Tag color={STATUS_COLORS[s]}>{STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '优先级', dataIndex: 'priority', width: 80, align: 'center' as const, render: (p: string) => <Tag color={PRIORITY_COLORS[p]}>{PRIORITY_LABELS[p] || p}</Tag> },
|
||||
{ title: '工程阶段', dataIndex: 'phase', width: 110, render: (v: string) => v || '-' },
|
||||
{ title: '子工程', dataIndex: 'subProject', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: '影响度', dataIndex: 'impactLevel', width: 80, align: 'center' as const, render: (v: string) => v ? <span style={{ color: IMPACT_ORDER[v] >= 3 ? '#ff4d4f' : '#999' }}>{v}</span> : '-' },
|
||||
{ title: '对应者', dataIndex: 'assigneeName', width: 90, render: (v: string) => v || <span style={{ color: '#bbb' }}>未分配</span> },
|
||||
{ title: '处理提示', key: 'tip', render: (_: any, r: Issue) => {
|
||||
const s = tipOf(r)
|
||||
return s ? (
|
||||
<div style={{ borderLeft: `3px solid ${s.color}`, paddingLeft: 8 }}>
|
||||
<div style={{ fontSize: 11, color: s.color, fontWeight: 500 }}>{s.text}</div>
|
||||
</div>
|
||||
) : <span style={{ color: '#ccc' }}>—</span>
|
||||
} },
|
||||
{ title: '截止日期', dataIndex: 'deadline', width: 105, render: (v: string) => v ? <span style={{ color: v && v.slice(0, 10) < new Date().toISOString().slice(0, 10) ? '#ff4d4f' : undefined }}>{v.slice(0, 10)}</span> : '-' },
|
||||
{ title: '操作', key: 'action', width: 140, align: 'right' as const, render: (_: any, r: Issue) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/issues/${r.id}`)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/issues/${r.id}/edit`)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) }
|
||||
]
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteIssue(id)
|
||||
message.success('已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
const openAssign = () => {
|
||||
if (selectedKeys.length === 0) return message.warning('请先选择指摘')
|
||||
setAssigneeId(undefined)
|
||||
setAssignModalOpen(true)
|
||||
}
|
||||
|
||||
const openNotify = () => {
|
||||
if (selectedKeys.length === 0) return message.warning('请先选择指摘')
|
||||
handleBatchNotify()
|
||||
}
|
||||
|
||||
const openAgent = () => {
|
||||
if (selectedKeys.length === 0) return message.warning('请先选择指摘')
|
||||
setBatchGoal('')
|
||||
setAgentModalOpen(true)
|
||||
}
|
||||
|
||||
const filterSelect = (label: string, key: string, options: Array<string | { label: string; value: string }>) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, color: '#999', marginBottom: 4, fontWeight: 600 }}>{label}</div>
|
||||
<Select
|
||||
allowClear placeholder="全部" style={{ width: '100%' }} value={filters[key]}
|
||||
onChange={v => setFilters({ ...filters, [key]: v })}
|
||||
options={options.map(o => typeof o === 'string' ? { label: o, value: o } : o)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>指摘列表</Typography.Title>
|
||||
<Space>
|
||||
<Button icon={<OrderedListOutlined />} onClick={() => navigate('/batch-input')}>批量录入</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出 CSV</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/issues/new')}>新建指摘</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
{STATUS_CARD_ORDER.map((st) => (
|
||||
<Col span={4} key={st}>
|
||||
<Card size="small" hoverable onClick={() => clickStatusCard(st)} style={{ cursor: 'pointer', borderBottom: `3px solid ${STATUS_CARD_COLORS[st]}` }}>
|
||||
<Statistic title={STATUS_LABELS[st]} value={summary[st] ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card title="筛选条件" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={4}>{filterSelect('工程阶段', 'phase', PHASE_OPTIONS)}</Col>
|
||||
<Col span={4}>{filterSelect('子工程', 'subProject', SUB_PROJECT_OPTIONS)}</Col>
|
||||
<Col span={4}>{filterSelect('优先级', 'priority', PRIORITY_OPTIONS)}</Col>
|
||||
<Col span={4}>{filterSelect('影响度', 'impactLevel', IMPACT_LEVEL_OPTIONS)}</Col>
|
||||
<Col span={4}>{filterSelect('对应者', 'assigneeId', assigneeOptions)}</Col>
|
||||
<Col span={4}>{filterSelect('归属部门', 'departmentId', deptOptions)}</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, color: '#999', marginBottom: 4, fontWeight: 600 }}>创建日期范围</div>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }} value={dateRange}
|
||||
onChange={(range: any) => {
|
||||
setDateRange(range)
|
||||
setFilters({
|
||||
...filters,
|
||||
startDate: range?.[0] ? range[0].format('YYYY-MM-DDT00:00:00') : undefined,
|
||||
endDate: range?.[1] ? range[1].format('YYYY-MM-DDT23:59:59') : undefined
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', height: '100%' }}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />} placeholder="搜索指摘 ID、标题或关键词"
|
||||
value={keyword} onChange={e => setKeyword(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>应用筛选</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div style={{ background: '#e6f4ff', border: '1px solid #91caff', borderRadius: 8, padding: 12, marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<RobotOutlined style={{ color: '#1677ff' }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>Agent 批量处理</span>
|
||||
<span style={{ fontSize: 11, color: '#1677ff' }}>选中多条后可使用 Agent 统一分配 / 催办</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button size="small" icon={<RobotOutlined />} onClick={openAssign}>统一分配</Button>
|
||||
<Button size="small" icon={<RobotOutlined />} onClick={openNotify}>批量催办</Button>
|
||||
<Button size="small" type="primary" onClick={openAgent}>智能分析</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowKey="id" dataSource={data} columns={columns} loading={loading}
|
||||
rowSelection={{ selectedRowKeys: selectedKeys, onChange: setSelectedKeys }}
|
||||
pagination={{ current: page, total, pageSize: 10, onChange: (p) => load(p), showTotal: t => `共 ${t} 条` }}
|
||||
/>
|
||||
|
||||
<Modal title="Agent 统一分配" open={assignModalOpen} onOk={handleBatchAssign}
|
||||
confirmLoading={batchLoading} onCancel={() => setAssignModalOpen(false)} okText="确认分配">
|
||||
<div style={{ marginBottom: 12, fontSize: 12, color: '#999' }}>
|
||||
已选中 {selectedKeys.length} 条指摘,选择担当者后统一分配。
|
||||
</div>
|
||||
<Select
|
||||
style={{ width: '100%' }} placeholder="选择担当者" value={assigneeId}
|
||||
onChange={setAssigneeId}
|
||||
options={USERS.map(u => ({ label: u.name, value: u.id }))}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal title="Agent 智能分析" open={agentModalOpen} onOk={handleBatchAgent}
|
||||
confirmLoading={batchLoading} onCancel={() => setAgentModalOpen(false)} okText="执行分析">
|
||||
<div style={{ marginBottom: 12, fontSize: 12, color: '#999' }}>
|
||||
已选中 {selectedKeys.length} 条指摘,Agent 将检索知识库相似案例并生成对应方案(需到详情页审批)。
|
||||
</div>
|
||||
<Input.TextArea rows={3} placeholder="例如:检索知识库相似案例,生成对应方案"
|
||||
value={batchGoal} onChange={e => setBatchGoal(e.target.value)} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Typography, Button, Space, Upload, Card } from 'antd'
|
||||
import { message } from '../../antdStatic'
|
||||
import { ArrowLeftOutlined, SaveOutlined, UploadOutlined, ClearOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import IssueForm from './IssueForm'
|
||||
import type { IssueFormValues } from './IssueForm'
|
||||
import { createIssue, uploadAttachment } from './services'
|
||||
|
||||
export default function IssueNewPage() {
|
||||
const navigate = useNavigate()
|
||||
const formRef = useRef<{ form: any }>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const handleSave = () => {
|
||||
formRef.current?.form.submit()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
formRef.current?.form.resetFields()
|
||||
setFiles([])
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: IssueFormValues) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res: any = await createIssue(values as Record<string, unknown>)
|
||||
const id = res.data?.id
|
||||
if (id) {
|
||||
for (const f of files) {
|
||||
await uploadAttachment(id, f)
|
||||
}
|
||||
}
|
||||
message.success('保存成功')
|
||||
navigate(id ? `/issues/${id}` : '/issues')
|
||||
} catch { /* error shown by interceptor */ }
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/issues')} />
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>新建指摘</Typography.Title>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button icon={<ClearOutlined />} onClick={handleReset}>清空重置</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={submitting} onClick={handleSave}>保存指摘</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<IssueForm ref={formRef} mode="create" submitting={submitting} onSubmit={handleSubmit} />
|
||||
|
||||
<Card style={{ maxWidth: 1280, margin: '0 auto 24px' }} title="附件">
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.log,.xlsx,.xls"
|
||||
beforeUpload={(file) => { setFiles(prev => [...prev, file]); return false }}
|
||||
showUploadList={{ removeIcon: true }}
|
||||
onRemove={(file: any) => setFiles(prev => prev.filter(f => f.name !== file.name))}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><UploadOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||
<p className="ant-upload-hint">支持图片、文档、日志文件,单个不超过 20MB(保存指摘后上传)</p>
|
||||
</Upload.Dragger>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import axios from 'axios'
|
||||
import request from '../../request'
|
||||
|
||||
export interface Issue {
|
||||
id: number
|
||||
issueNo: string
|
||||
title: string
|
||||
description?: string
|
||||
status: string
|
||||
priority: string
|
||||
deadline?: string
|
||||
phase?: string
|
||||
subProject?: string
|
||||
category?: string
|
||||
impactLevel?: string
|
||||
impactScope?: string
|
||||
deployment?: string
|
||||
pgmNo?: string
|
||||
reviewWorkload?: number
|
||||
responseWorkload?: number
|
||||
responseContent?: string
|
||||
ngReason?: string
|
||||
responseCompletedAt?: string
|
||||
confirmAt?: string
|
||||
creatorId?: number
|
||||
creatorName?: string
|
||||
assigneeId?: number
|
||||
assigneeName?: string
|
||||
departmentId?: number
|
||||
departmentName?: string
|
||||
reviewerId?: number
|
||||
reviewerName?: string
|
||||
validatorId?: number
|
||||
validatorName?: string
|
||||
agentStatus?: string
|
||||
agentLastPlanId?: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
closedAt?: string
|
||||
}
|
||||
|
||||
export interface IssueListParams {
|
||||
status?: string
|
||||
phase?: string
|
||||
subProject?: string
|
||||
priority?: string
|
||||
impactLevel?: string
|
||||
keyword?: string
|
||||
assigneeId?: number
|
||||
departmentId?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number
|
||||
fileName: string
|
||||
fileSize: number
|
||||
mimeType?: string
|
||||
filePath: string
|
||||
uploadedByName?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface AgentResult {
|
||||
planId: number
|
||||
status: string
|
||||
approvalStatus: string
|
||||
requiresApproval: boolean
|
||||
}
|
||||
|
||||
export interface IssueLogItem {
|
||||
action: string
|
||||
fromStatus?: string
|
||||
toStatus?: string
|
||||
remark?: string
|
||||
userName: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export const getIssueLogs = (id: number) =>
|
||||
request.get(`/issues/${id}/logs`)
|
||||
|
||||
export const getDepartments = () =>
|
||||
request.get('/departments')
|
||||
|
||||
export const downloadAttachment = async (issueId: number, attachmentId: number) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res = await axios.get(`/api/v1/issues/${issueId}/attachments/${attachmentId}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'blob'
|
||||
})
|
||||
return res.data as Blob
|
||||
}
|
||||
|
||||
export const listIssues = (params: IssueListParams) =>
|
||||
request.get('/issues', { params })
|
||||
|
||||
export const getIssue = (id: number) =>
|
||||
request.get(`/issues/${id}`)
|
||||
|
||||
export const createIssue = (data: Record<string, unknown>) =>
|
||||
request.post('/issues', data)
|
||||
|
||||
export const updateIssue = (id: number, data: Record<string, unknown>) =>
|
||||
request.put(`/issues/${id}`, data)
|
||||
|
||||
export const deleteIssue = (id: number) =>
|
||||
request.delete(`/issues/${id}`)
|
||||
|
||||
export const changeStatus = (id: number, status: string, remark?: string) =>
|
||||
request.patch(`/issues/${id}/status`, { status, remark })
|
||||
|
||||
export const changeAgentMode = (id: number, mode: string) =>
|
||||
request.post(`/issues/${id}/agent-mode`, null, { params: { mode } })
|
||||
|
||||
export const uploadAttachment = (issueId: number, file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post(`/issues/${issueId}/attachments`, formData)
|
||||
}
|
||||
|
||||
export const listAttachments = (issueId: number) =>
|
||||
request.get(`/issues/${issueId}/attachments`)
|
||||
|
||||
export const deleteAttachment = (issueId: number, attachmentId: number) =>
|
||||
request.delete(`/issues/${issueId}/attachments/${attachmentId}`)
|
||||
|
||||
export const executeAgent = (issueId: number | undefined, goal: string) =>
|
||||
request.post('/agent/execute', issueId ? { issueId, goal } : { goal })
|
||||
|
||||
export const suggestIssue = (issueId: number, goal: string) =>
|
||||
request.post('/agent/suggest', { issueId, goal })
|
||||
|
||||
export const suggestFields = (title: string, description?: string) =>
|
||||
request.post('/agent/suggest-fields', { title, description }, { timeout: 180000 })
|
||||
|
||||
export const getAgentStatus = (planId: number) =>
|
||||
request.get(`/agent/plan/${planId}/status`)
|
||||
|
||||
export const approveAgentPlan = (planId: number, comment?: string) =>
|
||||
request.post(`/agent/approval/${planId}/approve`, { comment })
|
||||
|
||||
export const rejectAgentPlan = (planId: number, comment?: string) =>
|
||||
request.post(`/agent/approval/${planId}/reject`, { comment })
|
||||
|
||||
export const exportIssues = async (params?: Record<string, unknown>) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res = await axios.get('/api/v1/issues/export', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params,
|
||||
responseType: 'blob'
|
||||
})
|
||||
return res.data as Blob
|
||||
}
|
||||
|
||||
export const batchAssign = (issueIds: number[], assigneeId: number) =>
|
||||
request.post('/issues/batch/assign', { issueIds, assigneeId })
|
||||
|
||||
export const batchNotify = (issueIds: number[], content?: string) =>
|
||||
request.post('/issues/batch/notify', { issueIds, content })
|
||||
|
||||
export const batchAgent = (issueIds: number[], goal: string) =>
|
||||
request.post('/issues/batch/agent', { issueIds, goal })
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Typography, Tabs, Upload, Table, Button, Input, Card, Statistic,
|
||||
Tag, Space, Modal, Select, Popconfirm, Row, Col
|
||||
} from 'antd'
|
||||
import { message } from '../../antdStatic'
|
||||
import { UploadOutlined, SearchOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import type { UploadFile } from 'antd/es/upload/interface'
|
||||
import { knowledgeApi, aiConfigApi, KnowledgeDoc, SearchResult, AiConfig } from './services'
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const [activeTab, setActiveTab] = useState('documents')
|
||||
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const [config, setConfig] = useState<AiConfig | null>(null)
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false)
|
||||
|
||||
const loadDocs = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await knowledgeApi.list(p)
|
||||
setDocs(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { loadDocs() }, [])
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
await knowledgeApi.upload(file)
|
||||
message.success('上传成功,正在解析中...')
|
||||
loadDocs()
|
||||
} catch {
|
||||
message.error('上传失败')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await knowledgeApi.delete(id)
|
||||
message.success('已删除')
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
const handleReindex = async (id: number) => {
|
||||
await knowledgeApi.reindex(id)
|
||||
message.success('重新索引已开始')
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
setSearching(true)
|
||||
try {
|
||||
const res: any = await knowledgeApi.search(searchQuery)
|
||||
setSearchResults(res.data?.items || [])
|
||||
} catch { /* ignore */ }
|
||||
setSearching(false)
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res: any = await aiConfigApi.get()
|
||||
setConfig(res.data)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!config) return
|
||||
await aiConfigApi.update(config)
|
||||
message.success('配置已保存')
|
||||
setConfigModalOpen(false)
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
completed: 'green', processing: 'orange', failed: 'red', pending: 'default'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '文件名', dataIndex: 'name', key: 'name' },
|
||||
{ title: '上传人', dataIndex: 'uploadedByName', key: 'uploadedByName' },
|
||||
{ title: '上传时间', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status',
|
||||
render: (s: string) => <Tag color={statusColor[s]}>{s}</Tag>
|
||||
},
|
||||
{
|
||||
title: '分块数', dataIndex: 'chunkCount', key: 'chunkCount',
|
||||
render: (v: number) => v ?? '-'
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action',
|
||||
render: (_: any, record: KnowledgeDoc) => (
|
||||
<Space>
|
||||
<Popconfirm title="确认重新向量化?" onConfirm={() => handleReindex(record.id)}>
|
||||
<Button type="link" icon={<ReloadOutlined />} size="small">重新索引</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>知识库管理</Typography.Title>
|
||||
<Button onClick={() => { loadConfig(); setConfigModalOpen(true) }}>Embedding 配置</Button>
|
||||
</div>
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.TabPane tab="文档管理" key="documents">
|
||||
<Upload.Dragger
|
||||
accept=".pdf,.docx,.doc,.txt,.md,.xlsx,.xls"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><UploadOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||
<p className="ant-upload-hint">支持 PDF, Word, TXT, Markdown, Excel,单文件不超过 50MB</p>
|
||||
</Upload.Dragger>
|
||||
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||
<Input.Search
|
||||
placeholder="检索知识库..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 400 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<Card title="检索结果" size="small" style={{ marginBottom: 16 }}>
|
||||
{searchResults.map(r => (
|
||||
<div key={r.chunkId} style={{ marginBottom: 12, padding: 8, background: '#f5f5f5', borderRadius: 6 }}>
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>
|
||||
{r.docName} · 相似度: {(r.score * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>{r.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Table
|
||||
dataSource={docs}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page, total, pageSize: 20,
|
||||
onChange: loadDocs, showTotal: t => `共 ${t} 个文档`
|
||||
}}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane tab="检索审计" key="audit">
|
||||
<AuditLogs />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<Modal title="Embedding 配置" open={configModalOpen} onOk={saveConfig} onCancel={() => setConfigModalOpen(false)}>
|
||||
{config && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<div>AI 提供商</div>
|
||||
<Select value={config.provider} onChange={v => setConfig({ ...config, provider: v })} style={{ width: '100%' }}>
|
||||
<Select.Option value="ollama">Ollama</Select.Option>
|
||||
<Select.Option value="deepseek">DeepSeek</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
{config.provider === 'ollama' && (
|
||||
<>
|
||||
<div>
|
||||
<div>Ollama 地址</div>
|
||||
<Input value={config.ollamaBaseUrl} onChange={e => setConfig({ ...config, ollamaBaseUrl: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div>Embedding 模型</div>
|
||||
<Input value={config.ollamaEmbeddingModel} onChange={e => setConfig({ ...config, ollamaEmbeddingModel: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{config.provider === 'deepseek' && (
|
||||
<>
|
||||
<div>
|
||||
<div>Embedding 模型</div>
|
||||
<Input value={config.deepseekEmbeddingModel} onChange={e => setConfig({ ...config, deepseekEmbeddingModel: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div>API Key</div>
|
||||
<Input.Password value={config.deepseekApiKey || ''} onChange={e => setConfig({ ...config, deepseekApiKey: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditLogs() {
|
||||
const [logs, setLogs] = useState<any[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const loadLogs = async (p = 1) => {
|
||||
try {
|
||||
const res: any = await knowledgeApi.logs(p)
|
||||
setLogs(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => { loadLogs() }, [])
|
||||
|
||||
const logColumns = [
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: '关键词', dataIndex: 'query', key: 'query' },
|
||||
{ title: '命中数', dataIndex: 'totalMatches', key: 'totalMatches' },
|
||||
{ title: '耗时(ms)', dataIndex: 'durationMs', key: 'durationMs' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}><Card><Statistic title="总检索次数" value={total} /></Card></Col>
|
||||
</Row>
|
||||
<Table
|
||||
dataSource={logs}
|
||||
columns={logColumns}
|
||||
rowKey="id"
|
||||
pagination={{ current: page, total, pageSize: 20, onChange: loadLogs }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import request from '../../request'
|
||||
|
||||
export interface KnowledgeDoc {
|
||||
id: number
|
||||
name: string
|
||||
fileSize: number
|
||||
fileType: string
|
||||
chunkCount: number
|
||||
status: string
|
||||
errorMessage: string
|
||||
uploadedByName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
chunkId: number
|
||||
content: string
|
||||
docName: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
provider: string
|
||||
ollamaBaseUrl: string
|
||||
ollamaChatModel: string
|
||||
ollamaEmbeddingModel: string
|
||||
ollamaTemperature?: number
|
||||
ollamaNumPredict?: number
|
||||
deepseekModel: string
|
||||
deepseekEmbeddingModel: string
|
||||
deepseekApiKey?: string
|
||||
autoFallbackEnabled: boolean
|
||||
agentMaxSteps?: number
|
||||
autoExecuteHighRisk?: boolean
|
||||
userRateLimit?: number
|
||||
}
|
||||
|
||||
export interface SearchLog {
|
||||
id: number
|
||||
query: string
|
||||
topK: number
|
||||
totalMatches: number
|
||||
durationMs: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const knowledgeApi = {
|
||||
list: (page = 1, pageSize = 20) =>
|
||||
request.get('/knowledge/documents', { params: { page, pageSize } }),
|
||||
|
||||
upload: (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post('/knowledge/documents', form)
|
||||
},
|
||||
|
||||
delete: (id: number) =>
|
||||
request.delete(`/knowledge/documents/${id}`),
|
||||
|
||||
reindex: (id: number) =>
|
||||
request.post(`/knowledge/documents/${id}/reindex`),
|
||||
|
||||
search: (query: string, topK = 5) =>
|
||||
request.get('/knowledge/search', { params: { query, topK } }),
|
||||
|
||||
logs: (page = 1, pageSize = 20) =>
|
||||
request.get('/knowledge/logs', { params: { page, pageSize } }),
|
||||
}
|
||||
|
||||
export const aiConfigApi = {
|
||||
get: () => request.get('/ai/config'),
|
||||
update: (config: AiConfig) => request.put('/ai/config', config),
|
||||
test: () => request.post('/ai/config/test'),
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Form, Input, Button, Card } from 'antd'
|
||||
import { message } from '../../antdStatic'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { login, fetchMe } from '../../store/slices/authSlice'
|
||||
import type { AppDispatch } from '../../store'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
await dispatch(login(values)).unwrap()
|
||||
dispatch(fetchMe())
|
||||
message.success('登录成功')
|
||||
navigate('/dashboard')
|
||||
} catch {
|
||||
message.error('账号或密码错误')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#f0f2f5' }}>
|
||||
<Card title="指摘管理系统" style={{ width: 400 }}>
|
||||
<Form onFinish={onFinish} size="large">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入账号' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>登 录</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import request from '../../request'
|
||||
|
||||
export interface AgentConfig {
|
||||
maxSteps: number
|
||||
autoExecuteHighRisk: boolean
|
||||
userRateLimit: number
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface AgentMemory {
|
||||
id: number
|
||||
issueSummary: string
|
||||
solutionSteps: string
|
||||
effectivenessScore: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ToolExecutionItem {
|
||||
id: number
|
||||
planId: number | null
|
||||
toolName: string
|
||||
status: string
|
||||
executionTimeMs: number | null
|
||||
outputResult: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface AgentOverview {
|
||||
todayExecutions: number
|
||||
todayPlans: number
|
||||
toolTotalCount: number
|
||||
toolSuccessCount: number
|
||||
toolSuccessRate: number
|
||||
pendingApprovals: number
|
||||
latestExecutions: ToolExecutionItem[]
|
||||
trendData: { hour: string; count: number }[]
|
||||
growthRate: number
|
||||
}
|
||||
|
||||
export interface AgentPlanItem {
|
||||
planId: number
|
||||
issueId: number
|
||||
issueNo: string
|
||||
issueTitle: string
|
||||
goal: string
|
||||
status: string
|
||||
requiresApproval: boolean
|
||||
approvalStatus: string
|
||||
approvalComment: string | null
|
||||
toolName: string | null
|
||||
toolParams: string | null
|
||||
approvalReason: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AgentTool {
|
||||
name: string
|
||||
description: string
|
||||
isWrite: boolean
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
provider: string
|
||||
ollamaBaseUrl: string
|
||||
ollamaChatModel: string
|
||||
ollamaEmbeddingModel: string
|
||||
deepseekModel: string
|
||||
deepseekEmbeddingModel: string
|
||||
autoFallbackEnabled: boolean
|
||||
agentMaxSteps: number
|
||||
autoExecuteHighRisk: boolean
|
||||
userRateLimit: number
|
||||
chunkSize: number
|
||||
chunkOverlap: number
|
||||
maxUploadSize: number
|
||||
}
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: number
|
||||
templateId: string
|
||||
name: string
|
||||
category: string
|
||||
version: number
|
||||
content: string
|
||||
variables: string | null
|
||||
outputSchema: string | null
|
||||
isActive: boolean
|
||||
isDefault: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface PromptVersion {
|
||||
id: number
|
||||
templateId: string
|
||||
version: number
|
||||
content: string
|
||||
changeLog: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PromptRenderLog {
|
||||
id: number
|
||||
requestId: string
|
||||
templateId: string
|
||||
templateVersion: number
|
||||
renderedPrompt: string
|
||||
variablesUsed: string
|
||||
tokensInput: number
|
||||
tokensOutput: number
|
||||
executionTimeMs: number
|
||||
llmModel: string
|
||||
modelProvider: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PromptStats {
|
||||
templateId: string
|
||||
name: string
|
||||
category: string
|
||||
latestVersion: number
|
||||
useCount: number
|
||||
avgExecutionTimeMs: number
|
||||
}
|
||||
|
||||
export interface AiCallLog {
|
||||
id: number
|
||||
provider: string
|
||||
model: string
|
||||
status: string
|
||||
latencyMs: number | null
|
||||
responseSnippet: string | null
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const agentApi = {
|
||||
config: () => request.get('/agent/config'),
|
||||
updateConfig: (config: { maxSteps: number; autoExecuteHighRisk: boolean; userRateLimit: number }) =>
|
||||
request.put('/agent/config', config),
|
||||
|
||||
overview: () => request.get('/agent/overview'),
|
||||
plans: (approvalStatus = 'requested', page = 1, pageSize = 20) =>
|
||||
request.get('/agent/plans', { params: { approvalStatus, page, pageSize } }),
|
||||
approve: (planId: number, comment?: string) =>
|
||||
request.post(`/agent/approval/${planId}/approve`, { comment }),
|
||||
reject: (planId: number, comment?: string) =>
|
||||
request.post(`/agent/approval/${planId}/reject`, { comment }),
|
||||
tools: () => request.get('/agent/tools'),
|
||||
|
||||
memories: (page = 1, pageSize = 20) =>
|
||||
request.get('/agent/memories', { params: { page, pageSize } }),
|
||||
createMemory: (issueSummary: string, solutionSteps: string) =>
|
||||
request.post('/agent/memories', { issueSummary, solutionSteps }),
|
||||
deleteMemory: (id: number) =>
|
||||
request.delete(`/agent/memories/${id}`),
|
||||
updateMemory: (id: number, data: { issueSummary: string; solutionSteps: string }) =>
|
||||
request.put(`/agent/memories/${id}`, data),
|
||||
}
|
||||
|
||||
export const aiConfigApi = {
|
||||
get: () => request.get('/ai/config'),
|
||||
update: (config: Partial<AiConfig>) => request.put('/ai/config', config),
|
||||
test: () => request.post('/ai/config/test'),
|
||||
}
|
||||
|
||||
export const aiAnalysisApi = {
|
||||
callLogs: () => request.get('/ai/call-logs'),
|
||||
}
|
||||
|
||||
export const promptApi = {
|
||||
list: (page = 1, pageSize = 20) =>
|
||||
request.get('/prompts', { params: { page, pageSize } }),
|
||||
detail: (templateId: string) =>
|
||||
request.get(`/prompts/${templateId}`),
|
||||
create: (data: { templateId: string; name: string; category: string; content: string; variables?: string; outputSchema?: string }) =>
|
||||
request.post('/prompts', data),
|
||||
update: (templateId: string, data: { name: string; category: string; content: string; variables?: string; outputSchema?: string }) =>
|
||||
request.put(`/prompts/${templateId}`, data),
|
||||
rollback: (templateId: string, version: number) =>
|
||||
request.post(`/prompts/${templateId}/rollback`, null, { params: { version } }),
|
||||
test: (templateId: string, variables?: Record<string, string>) =>
|
||||
request.post(`/prompts/${templateId}/test`, { variables }),
|
||||
versions: (templateId: string) =>
|
||||
request.get(`/prompts/${templateId}/versions`),
|
||||
logs: (page = 1, pageSize = 20) =>
|
||||
request.get('/prompts/logs', { params: { page, pageSize } }),
|
||||
stats: () => request.get('/prompts/stats'),
|
||||
}
|
||||
@@ -0,0 +1,1582 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Typography, Tabs, Card, Form, Input, InputNumber, Select, Button, Slider, Switch,
|
||||
Table, Modal, Tag, Space, message, Row, Col, Popconfirm,
|
||||
Progress, Tooltip, Rate, Alert, Descriptions, Statistic, Radio
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, RobotOutlined, ExperimentOutlined,
|
||||
CheckCircleOutlined, CloseCircleOutlined, ClockCircleOutlined,
|
||||
EyeOutlined, PlayCircleOutlined, ThunderboltOutlined,
|
||||
EditOutlined, DeleteOutlined, ReloadOutlined,
|
||||
CheckOutlined, CloseOutlined, HistoryOutlined, SafetyOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
agentApi, promptApi, aiConfigApi, aiAnalysisApi,
|
||||
AgentConfig, AgentMemory, AgentOverview, AgentPlanItem,
|
||||
AgentTool, AiConfig, PromptTemplate, PromptVersion, PromptRenderLog, PromptStats,
|
||||
AiCallLog, ToolExecutionItem as AgentExecution
|
||||
} from './agent-admin-services'
|
||||
|
||||
const primaryBtn = { background: '#1f2937', borderColor: '#1f2937' }
|
||||
|
||||
export default function AgentAdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>Agent 监控与管理</Typography.Title>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
|
||||
{ key: 'overview', label: '运行概览', children: <AgentOverviewTab /> },
|
||||
{ key: 'approval', label: '待审批队列', children: <AgentApprovalTab /> },
|
||||
{ key: 'agent', label: '全局配置', children: <AgentConfigTab /> },
|
||||
{ key: 'prompt', label: 'Prompt 模板管理', children: <PromptTab /> },
|
||||
{ key: 'memory', label: '记忆库', children: <MemoryManager /> },
|
||||
]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- 运行概览 ---------------- */
|
||||
function AgentOverviewTab() {
|
||||
const [overview, setOverview] = useState<AgentOverview | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
const [selectedExecution, setSelectedExecution] = useState<AgentExecution | null>(null)
|
||||
const trendChartRef = useRef<HTMLDivElement>(null)
|
||||
const chartInstanceRef = useRef<any>(null)
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await agentApi.overview()
|
||||
setOverview(res.data)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadOverview() }, [loadOverview])
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return <Tag color="success" icon={<CheckCircleOutlined />}>SUCCESS</Tag>
|
||||
case 'failed': return <Tag color="error" icon={<CloseCircleOutlined />}>FAILED</Tag>
|
||||
case 'pending': return <Tag color="warning" icon={<ClockCircleOutlined />}>WAIT_HUMAN</Tag>
|
||||
case 'running': return <Tag color="processing" icon={<PlayCircleOutlined />}>RUNNING</Tag>
|
||||
default: return <Tag>{status}</Tag>
|
||||
}
|
||||
}
|
||||
|
||||
const executionColumns: ColumnsType<AgentExecution> = [
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 180,
|
||||
render: (v?: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
{ title: '工具名称', dataIndex: 'toolName', key: 'toolName', width: 180, render: (v: string) => <code style={{ color: '#1890ff', fontWeight: 600 }}>{v}</code> },
|
||||
{ title: '执行结果', dataIndex: 'status', key: 'status', width: 120, render: (v: string) => getStatusTag(v) },
|
||||
{
|
||||
title: '耗时', dataIndex: 'executionTimeMs', key: 'executionTimeMs', width: 100,
|
||||
render: (v?: number) => v ? `${v}ms` : '-',
|
||||
},
|
||||
{
|
||||
title: '详情', key: 'action', width: 80, align: 'center',
|
||||
render: (_: unknown, record: AgentExecution) => (
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => {
|
||||
setSelectedExecution(record)
|
||||
setDetailVisible(true)
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const updateTrendChart = (chart: any, trendData: { hour: string; count: number }[]) => {
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: trendData.map(d => d.hour),
|
||||
axisLine: { lineStyle: { color: '#eee' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { type: 'dashed', color: '#f5f5f5' } },
|
||||
},
|
||||
series: [{
|
||||
name: '调用次数',
|
||||
type: 'bar',
|
||||
barWidth: '20%',
|
||||
data: trendData.map(d => d.count),
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1, global: false,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#1a73e8' },
|
||||
{ offset: 1, color: '#60a5fa' },
|
||||
],
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const node = trendChartRef.current
|
||||
if (!node) return
|
||||
const chart = echarts.init(node)
|
||||
chartInstanceRef.current = chart
|
||||
const resizeHandler = () => chart.resize()
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeHandler)
|
||||
chart.dispose()
|
||||
chartInstanceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstanceRef.current && overview?.trendData) {
|
||||
updateTrendChart(chartInstanceRef.current, overview.trendData)
|
||||
}
|
||||
}, [overview?.trendData])
|
||||
|
||||
const growthRate = overview?.growthRate ?? 0
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{/* 4个统计卡片 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#999', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
当前运行状态
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 12,
|
||||
background: '#f6ffed', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#52c41a', fontSize: 24
|
||||
}}>
|
||||
<ThunderboltOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#262626', lineHeight: 1.2 }}>健康</div>
|
||||
<div style={{ fontSize: 12, color: '#52c41a', fontWeight: 500, fontStyle: 'italic', marginTop: 4 }}>
|
||||
响应延迟 240ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#999', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
今日调用次数
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color: '#262626' }}>
|
||||
{(overview?.todayExecutions ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 8 }}>
|
||||
<span style={{ color: '#1890ff', fontWeight: 700 }}>
|
||||
{growthRate >= 0 ? '+' : ''}{growthRate.toFixed(0)}%
|
||||
</span> 较昨日平均
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#999', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
工具执行成功率
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color: '#262626' }}>
|
||||
{(overview?.toolSuccessRate ?? 0).toFixed(1)}%
|
||||
</div>
|
||||
<Progress
|
||||
percent={overview?.toolSuccessRate ?? 0}
|
||||
showInfo={false}
|
||||
strokeColor={(overview?.toolSuccessRate ?? 0) >= 90 ? '#52c41a' : '#faad14'}
|
||||
size="small"
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#999', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
人工介入请求
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color: '#262626' }}>
|
||||
{overview?.todayPlans ?? 0}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 8 }}>
|
||||
待处理 <span style={{ color: '#ff4d4f', fontWeight: 700 }}>{overview?.pendingApprovals ?? 0}</span> 条
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 执行日志 + 趋势图 */}
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Agent 执行日志记录"
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={loadOverview} loading={loading} size="small">
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={executionColumns}
|
||||
dataSource={overview?.latestExecutions ?? []}
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="工具调用频率趋势" style={{ height: '100%' }}>
|
||||
<div ref={trendChartRef} style={{ height: 350 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title="执行详情"
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={null}
|
||||
width={640}
|
||||
>
|
||||
{selectedExecution && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="工具名称">
|
||||
<code style={{ color: '#1890ff' }}>{selectedExecution.toolName}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="执行状态">
|
||||
{getStatusTag(selectedExecution.status)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="执行耗时">
|
||||
{selectedExecution.executionTimeMs ? `${selectedExecution.executionTimeMs}ms` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="执行时间">
|
||||
{selectedExecution.createdAt ? dayjs(selectedExecution.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{selectedExecution.outputResult && (
|
||||
<div>
|
||||
<Typography.Text strong>执行结果:</Typography.Text>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
background: '#f5f5f5',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
marginTop: 8
|
||||
}}>
|
||||
{selectedExecution.outputResult}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentApprovalTab() {
|
||||
const [plans, setPlans] = useState<AgentPlanItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [approvalLoading, setApprovalLoading] = useState<number | null>(null)
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
const [selectedPlan, setSelectedPlan] = useState<AgentPlanItem | null>(null)
|
||||
|
||||
const loadPlans = useCallback(async (p = page, ps = pageSize) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await agentApi.plans('requested', p, ps)
|
||||
setPlans(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [page, pageSize])
|
||||
|
||||
useEffect(() => { loadPlans() }, [loadPlans])
|
||||
|
||||
const handleApprove = async (planId: number) => {
|
||||
setApprovalLoading(planId)
|
||||
try {
|
||||
await agentApi.approve(planId)
|
||||
message.success('已批准')
|
||||
loadPlans()
|
||||
} catch { /* ignore */ }
|
||||
setApprovalLoading(null)
|
||||
}
|
||||
|
||||
const handleReject = async (planId: number) => {
|
||||
setApprovalLoading(planId)
|
||||
try {
|
||||
await agentApi.reject(planId)
|
||||
message.success('已拒绝')
|
||||
loadPlans()
|
||||
} catch { /* ignore */ }
|
||||
setApprovalLoading(null)
|
||||
}
|
||||
|
||||
const getRiskLevel = (toolName?: string | null) => {
|
||||
const highRiskTools = ['delete_issue', 'close_issue']
|
||||
const mediumRiskTools = ['assign_issue', 'update_status']
|
||||
if (highRiskTools.includes(toolName || '')) return { color: 'red', text: '高风险' }
|
||||
if (mediumRiskTools.includes(toolName || '')) return { color: 'orange', text: '中风险' }
|
||||
return { color: 'green', text: '低风险' }
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AgentPlanItem> = [
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
|
||||
render: (v?: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作类型', dataIndex: 'toolName', key: 'toolName', width: 160,
|
||||
render: (v: string) => <code style={{ color: '#1890ff' }}>{v}</code>,
|
||||
},
|
||||
{
|
||||
title: '关联指摘', key: 'issue', width: 160,
|
||||
render: (_: unknown, record: AgentPlanItem) => (
|
||||
record.issueNo ? <Tag color="blue">{record.issueNo}</Tag> : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '目标描述', dataIndex: 'goal', key: 'goal', ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '风险等级', key: 'risk', width: 100,
|
||||
render: (_: unknown, record: AgentPlanItem) => {
|
||||
const risk = getRiskLevel(record.toolName)
|
||||
return <Tag color={risk.color}>{risk.text}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 200, align: 'center',
|
||||
render: (_: unknown, record: AgentPlanItem) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
loading={approvalLoading === record.planId}
|
||||
onClick={() => handleApprove(record.planId)}
|
||||
>
|
||||
批准
|
||||
</Button>
|
||||
<Popconfirm title="确认拒绝此操作?" onConfirm={() => handleReject(record.planId)}>
|
||||
<Button size="small">拒绝</Button>
|
||||
</Popconfirm>
|
||||
<Button type="link" size="small" onClick={() => {
|
||||
setSelectedPlan(record)
|
||||
setDetailVisible(true)
|
||||
}}>
|
||||
详情
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{total > 0 && (
|
||||
<Alert
|
||||
message={`当前有 ${total} 个 Agent 发起的操作等待人工审批,超时 48 小时将自动触发通知升级。`}
|
||||
type="warning"
|
||||
showIcon
|
||||
banner
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="审批队列"
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={() => loadPlans()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="planId"
|
||||
columns={columns}
|
||||
dataSource={plans}
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => loadPlans(p, ps),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="审批详情"
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={
|
||||
selectedPlan ? (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={approvalLoading === selectedPlan.planId}
|
||||
onClick={() => {
|
||||
handleApprove(selectedPlan.planId)
|
||||
setDetailVisible(false)
|
||||
}}
|
||||
>
|
||||
批准
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认拒绝此操作?"
|
||||
onConfirm={() => {
|
||||
handleReject(selectedPlan.planId)
|
||||
setDetailVisible(false)
|
||||
}}
|
||||
>
|
||||
<Button>拒绝</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
width={640}
|
||||
>
|
||||
{selectedPlan && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="操作类型">
|
||||
<code style={{ color: '#1890ff' }}>{selectedPlan.toolName}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="风险等级">
|
||||
<Tag color={getRiskLevel(selectedPlan.toolName).color}>
|
||||
{getRiskLevel(selectedPlan.toolName).text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联指摘">
|
||||
{selectedPlan.issueNo ? <Tag color="blue">{selectedPlan.issueNo}</Tag> : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{selectedPlan.createdAt ? dayjs(selectedPlan.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div>
|
||||
<Typography.Text strong>目标描述:</Typography.Text>
|
||||
<div style={{ marginTop: 8, padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
|
||||
{selectedPlan.goal}
|
||||
</div>
|
||||
</div>
|
||||
{selectedPlan.approvalReason && (
|
||||
<div>
|
||||
<Typography.Text strong>审批原因:</Typography.Text>
|
||||
<div style={{ marginTop: 8, padding: 12, background: '#fff7e6', borderRadius: 6, border: '1px solid #ffd591' }}>
|
||||
{selectedPlan.approvalReason}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedPlan.toolParams && (
|
||||
<div>
|
||||
<Typography.Text strong>工具参数:</Typography.Text>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
background: '#f5f5f5',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
marginTop: 8
|
||||
}}>
|
||||
{selectedPlan.toolParams}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentConfigTab() {
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<any>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [aiRes, agentRes]: any = await Promise.all([aiConfigApi.get(), agentApi.config()])
|
||||
const ai: AiConfig = aiRes.data || {}
|
||||
const agent: AgentConfig = agentRes.data || {}
|
||||
form.setFieldsValue({
|
||||
...ai,
|
||||
maxSteps: agent.maxSteps,
|
||||
autoExecuteHighRisk: agent.autoExecuteHighRisk,
|
||||
userRateLimit: agent.userRateLimit,
|
||||
maxUploadSize: ai.maxUploadSize ? Math.round(ai.maxUploadSize / 1024 / 1024) : 50,
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const saveConfig = async () => {
|
||||
const values = form.getFieldsValue()
|
||||
setSaving(true)
|
||||
try {
|
||||
await aiConfigApi.update({
|
||||
provider: values.provider,
|
||||
ollamaBaseUrl: values.ollamaBaseUrl,
|
||||
ollamaChatModel: values.ollamaChatModel,
|
||||
ollamaEmbeddingModel: values.ollamaEmbeddingModel,
|
||||
deepseekModel: values.deepseekModel,
|
||||
deepseekEmbeddingModel: values.deepseekEmbeddingModel,
|
||||
autoFallbackEnabled: values.autoFallbackEnabled,
|
||||
chunkSize: values.chunkSize,
|
||||
chunkOverlap: values.chunkOverlap,
|
||||
maxUploadSize: Number(values.maxUploadSize || 0) * 1024 * 1024,
|
||||
})
|
||||
await agentApi.updateConfig({
|
||||
maxSteps: values.maxSteps,
|
||||
autoExecuteHighRisk: values.autoExecuteHighRisk,
|
||||
userRateLimit: values.userRateLimit,
|
||||
})
|
||||
message.success('配置已保存')
|
||||
setTestResult(null)
|
||||
} catch { /* ignore */ }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const testModel = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const res: any = await aiConfigApi.test()
|
||||
setTestResult(res.data)
|
||||
} catch { /* ignore */ }
|
||||
setTesting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Form form={form} layout="vertical" onFinish={saveConfig} disabled={loading}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="AI 模型配置" style={{ borderRadius: 16 }}>
|
||||
<Form.Item name="provider" label="推理引擎" tooltip="ollama 本地推理 / deepseek 云端 API">
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Ollama(本地)', value: 'ollama' },
|
||||
{ label: 'DeepSeek(云端 API)', value: 'deepseek' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="ollamaBaseUrl" label="Ollama 地址">
|
||||
<Input placeholder="http://localhost:11434" />
|
||||
</Form.Item>
|
||||
<Form.Item name="ollamaChatModel" label="Ollama 对话模型">
|
||||
<Input placeholder="llama3.1:8b" />
|
||||
</Form.Item>
|
||||
<Form.Item name="ollamaEmbeddingModel" label="Ollama 向量模型">
|
||||
<Input placeholder="nomic-embed-text" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deepseekModel" label="DeepSeek 对话模型">
|
||||
<Input placeholder="deepseek-v4-pro" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deepseekEmbeddingModel" label="DeepSeek 向量模型">
|
||||
<Input placeholder="text-embedding-3-small" />
|
||||
</Form.Item>
|
||||
<Form.Item name="autoFallbackEnabled" label="主引擎失败自动降级" valuePropName="checked"
|
||||
tooltip="主引擎调用失败时自动切换到另一引擎">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<div className="space-y-6" style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<Card title="Agent 行为配置" style={{ borderRadius: 16 }}>
|
||||
<Form.Item name="maxSteps" label="最大推理步数">
|
||||
<Slider min={3} max={20} marks={{ 3: '3', 10: '10', 20: '20' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="autoExecuteHighRisk" label="允许自动执行高风险操作" valuePropName="checked"
|
||||
tooltip="开启后写操作(删除/跨部门分配等)无需人工审批">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="userRateLimit" label="单用户每分钟调用上限">
|
||||
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card title="知识库切片配置" style={{ borderRadius: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="chunkSize" label="切片大小 (Token)">
|
||||
<InputNumber min={50} max={2000} step={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="chunkOverlap" label="重叠 Token 数">
|
||||
<InputNumber min={0} max={500} step={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="maxUploadSize" label="最大上传大小 (MB)">
|
||||
<InputNumber min={1} max={200} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button icon={<ExperimentOutlined />} loading={testing} onClick={testModel}>测试模型连通性</Button>
|
||||
<Button type="primary" style={primaryBtn} loading={saving} onClick={() => form.submit()}>保存配置</Button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<Alert
|
||||
style={{ marginTop: 8 }}
|
||||
type={testResult.success ? 'success' : 'error'}
|
||||
showIcon
|
||||
message={testResult.success ? '模型连接正常' : '模型连接失败'}
|
||||
description={
|
||||
<span>
|
||||
引擎:{testResult.provider} · 模型:{testResult.model || '-'}
|
||||
{testResult.latencyMs != null && ` · 耗时 ${testResult.latencyMs}ms`}
|
||||
{testResult.error && <div>{testResult.error}</div>}
|
||||
{testResult.reply && <div>回复:{testResult.reply}</div>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewTab() {
|
||||
const [overview, setOverview] = useState<AgentOverview | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [callLogs, setCallLogs] = useState<AiCallLog[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await agentApi.overview()
|
||||
setOverview(res.data)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
const loadCallLogs = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await aiAnalysisApi.callLogs()
|
||||
setCallLogs(res.data || [])
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
loadCallLogs()
|
||||
const timer = window.setInterval(loadCallLogs, 3000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loadCallLogs])
|
||||
|
||||
const healthy = (overview?.toolSuccessRate ?? 100) >= 90
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
success: 'green', running: 'processing', processing: 'processing', pending: 'orange',
|
||||
failed: 'red', rejected: 'red', waiting_approval: 'orange'
|
||||
}
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
ai_analysis: 'AI 智能分析'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 170,
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', color: '#999' }}>{v || '-'}</span>
|
||||
},
|
||||
{
|
||||
title: '工具名称', dataIndex: 'toolName', key: 'toolName',
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#1677ff' }}>{toolLabels[v] || v}</span>
|
||||
},
|
||||
{
|
||||
title: '执行结果', dataIndex: 'status', key: 'status', width: 120,
|
||||
render: (v: string) => <Tag color={statusColor[v] || 'default'}>{v}</Tag>
|
||||
},
|
||||
{
|
||||
title: '耗时', dataIndex: 'executionTimeMs', key: 'executionTimeMs', width: 100,
|
||||
render: (v: number) => (v != null ? <span>{v}ms</span> : '-')
|
||||
},
|
||||
{
|
||||
title: '输出', dataIndex: 'outputResult', key: 'outputResult', ellipsis: true,
|
||||
render: (v: string) => v || '-'
|
||||
}
|
||||
]
|
||||
|
||||
const callLogColumns = [
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 170,
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', color: '#999' }}>{v || '-'}</span>
|
||||
},
|
||||
{
|
||||
title: '引擎 / 模型', key: 'model', width: 190,
|
||||
render: (_: any, l: AiCallLog) => (
|
||||
<span>
|
||||
<Tag color={l.provider === 'ollama' ? 'blue' : 'purple'}>{l.provider || '-'}</Tag>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{l.model || '-'}</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag>
|
||||
},
|
||||
{
|
||||
title: '耗时', dataIndex: 'latencyMs', key: 'latencyMs', width: 90,
|
||||
render: (v: number) => (v != null ? <span>{v}ms</span> : '-')
|
||||
},
|
||||
{
|
||||
title: '返回内容', key: 'result', ellipsis: true,
|
||||
render: (_: any, l: AiCallLog) => (
|
||||
l.status === 'success'
|
||||
? <span style={{ color: '#555' }}>{l.responseSnippet || '-'}</span>
|
||||
: <span style={{ color: '#cf1322' }}>{l.errorMessage || '-'}</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Statistic
|
||||
title="当前运行状态"
|
||||
value={healthy ? '健康' : '异常'}
|
||||
valueStyle={{ color: healthy ? '#16a34a' : '#dc2626' }}
|
||||
prefix={<SafetyOutlined />}
|
||||
suffix={overview && <Tag color={healthy ? 'green' : 'red'}>{overview.toolSuccessRate}%</Tag>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Statistic title="今日 Agent 执行次数" value={overview?.todayExecutions ?? 0} />
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 8 }}>今日新建计划 {overview?.todayPlans ?? 0} 个</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Statistic title="工具执行成功率" value={overview?.toolSuccessRate ?? 0} suffix="%" precision={1}
|
||||
valueStyle={{ color: (overview?.toolSuccessRate ?? 0) >= 90 ? '#16a34a' : '#d97706' }} />
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 8 }}>成功 {overview?.toolSuccessCount ?? 0} / 共 {overview?.toolTotalCount ?? 0} 次</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Statistic title="待人工审批" value={overview?.pendingApprovals ?? 0}
|
||||
valueStyle={{ color: (overview?.pendingApprovals ?? 0) > 0 ? '#dc2626' : undefined }} />
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 8 }}>Agent 发起的敏感操作等待确认</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="AI 调用实时监控" style={{ borderRadius: 16 }} styles={{ body: { padding: 0 } }}
|
||||
extra={<span style={{ fontSize: 12, color: '#999' }}>最近 20 次模型调用 · 每 3 秒自动刷新</span>}>
|
||||
<Table
|
||||
dataSource={callLogs}
|
||||
columns={callLogColumns}
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Agent 执行日志记录" style={{ borderRadius: 16 }} styles={{ body: { padding: 0 } }}
|
||||
extra={<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>}>
|
||||
<Table
|
||||
dataSource={overview?.latestExecutions || []}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="middle"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- 待审批队列 ---------------- */
|
||||
|
||||
function ApprovalTab() {
|
||||
const [plans, setPlans] = useState<AgentPlanItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<AgentPlanItem | null>(null)
|
||||
|
||||
const load = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await agentApi.plans('requested', p)
|
||||
setPlans(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const decide = async (planId: number, approve: boolean) => {
|
||||
try {
|
||||
await (approve ? agentApi.approve(planId) : agentApi.reject(planId))
|
||||
message.success(approve ? '已批准' : '已拒绝')
|
||||
load(page)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const riskTag = (toolName: string) => {
|
||||
const write = ['delete_issue', 'close_issue', 'update_issue', 'assign_issue', 'update_status', 'send_reminder']
|
||||
const isWrite = write.includes(toolName || '')
|
||||
return isWrite
|
||||
? <Tag color="red">高风险</Tag>
|
||||
: <Tag color="blue">低风险</Tag>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{total > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`当前有 ${total} 个 Agent 发起的操作等待人工审批`}
|
||||
description="批准后 Agent 将继续执行后续步骤;拒绝将终止该计划。"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card title={`审批队列(${total})`} style={{ borderRadius: 16 }}>
|
||||
<Table
|
||||
dataSource={plans}
|
||||
rowKey="planId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page, total, pageSize: 20,
|
||||
onChange: load, showTotal: t => `共 ${t} 条`,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '操作请求', key: 'action', width: 220,
|
||||
render: (_: any, r: AgentPlanItem) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{r.toolName || '-'} 调用请求</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>{r.createdAt}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '目标指摘', key: 'issue', width: 200,
|
||||
render: (_: any, r: AgentPlanItem) => (
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#1677ff' }}>{r.issueNo || '-'}</span>
|
||||
<div style={{ fontSize: 12, color: '#666', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 160 }}>{r.issueTitle}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ title: '目标', dataIndex: 'goal', key: 'goal', ellipsis: true },
|
||||
{
|
||||
title: '风险', key: 'risk', width: 90,
|
||||
render: (_: any, r: AgentPlanItem) => riskTag(r.toolName || '')
|
||||
},
|
||||
{
|
||||
title: '审批原因', dataIndex: 'approvalReason', key: 'reason', ellipsis: true,
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'op', width: 190, align: 'right' as const,
|
||||
render: (_: any, r: AgentPlanItem) => (
|
||||
<Space>
|
||||
<Button size="small" type="primary" danger icon={<CheckOutlined />} onClick={() => decide(r.planId, true)}>批准</Button>
|
||||
<Button size="small" icon={<CloseOutlined />} onClick={() => decide(r.planId, false)}>拒绝</Button>
|
||||
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => setDetail(r)}>详情</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal title="审批详情" open={!!detail} footer={<Button onClick={() => setDetail(null)}>关闭</Button>} width={640}
|
||||
onCancel={() => setDetail(null)}>
|
||||
{detail && (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div><b>工具:</b><Tag color="blue">{detail.toolName || '-'}</Tag>{riskTag(detail.toolName || '')}</div>
|
||||
<div><b>指摘:</b><span style={{ fontFamily: 'monospace' }}>{detail.issueNo}</span> · {detail.issueTitle}</div>
|
||||
<div><b>目标:</b>{detail.goal}</div>
|
||||
<div><b>审批原因:</b>{detail.approvalReason || '-'}</div>
|
||||
<div><b>参数:</b></div>
|
||||
<pre style={{ background: '#fafafa', borderRadius: 8, padding: 12, fontSize: 12, whiteSpace: 'pre-wrap' }}>
|
||||
{detail.toolParams || '-'}
|
||||
</pre>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>发起时间:{detail.createdAt}</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- 工具管理 ---------------- */
|
||||
|
||||
function ToolsTab() {
|
||||
const [tools, setTools] = useState<AgentTool[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await agentApi.tools()
|
||||
setTools(res.data || [])
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '工具名称', dataIndex: 'name', key: 'name',
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#1677ff' }}>{v}</span>
|
||||
},
|
||||
{ title: '描述', dataIndex: 'description', key: 'description' },
|
||||
{
|
||||
title: '状态', key: 'status', width: 100,
|
||||
render: (_: any, r: AgentTool) => r.isWrite
|
||||
? <Tag color="orange">需审批</Tag>
|
||||
: <Tag color="green">启用</Tag>
|
||||
},
|
||||
{
|
||||
title: '风险等级', key: 'risk', width: 100,
|
||||
render: (_: any, r: AgentTool) => r.isWrite
|
||||
? <Tag color="red">高风险</Tag>
|
||||
: <Tag color="blue">低风险</Tag>
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Card title="已注册工具列表" style={{ borderRadius: 16 }} styles={{ body: { padding: 0 } }}
|
||||
extra={<span style={{ fontSize: 12, color: '#999' }}>写操作为高风险工具,默认需人工审批</span>}>
|
||||
<Table dataSource={tools} columns={columns} rowKey="name" loading={loading} pagination={false} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- 记忆库 ---------------- */
|
||||
|
||||
function MemoryManager() {
|
||||
const [memories, setMemories] = useState<AgentMemory[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [addForm] = Form.useForm()
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AgentMemory | null>(null)
|
||||
const [editForm] = Form.useForm()
|
||||
|
||||
const load = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await agentApi.memories(p)
|
||||
setMemories(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const addMemory = async (values: any) => {
|
||||
try {
|
||||
await agentApi.createMemory(values.issueSummary, values.solutionSteps)
|
||||
message.success('记忆已添加')
|
||||
setAddOpen(false)
|
||||
addForm.resetFields()
|
||||
load()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const delMemory = async (id: number) => {
|
||||
try {
|
||||
await agentApi.deleteMemory(id)
|
||||
message.success('已删除')
|
||||
load()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const updateMemory = async (values: any) => {
|
||||
if (!editing) return
|
||||
try {
|
||||
await agentApi.updateMemory(editing.id, values)
|
||||
message.success('记忆已更新')
|
||||
setEditOpen(false)
|
||||
setEditing(null)
|
||||
load()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const avgScore = memories.length
|
||||
? (memories.reduce((s, m) => s + (Number(m.effectivenessScore) || 0), 0) / memories.length).toFixed(1)
|
||||
: '0'
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '问题摘要', dataIndex: 'issueSummary', key: 'issueSummary', width: 280,
|
||||
render: (v: string) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>{v}</div>
|
||||
<Space size={4}>
|
||||
{(v || '').split(/[,,、\s]+/).filter(Boolean).slice(0, 3).map((tag, i) => (
|
||||
<Tag key={i} style={{ fontSize: 10, borderRadius: 4 }}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '解决方案步骤', dataIndex: 'solutionSteps', key: 'solutionSteps', width: 300,
|
||||
render: (v: string) => {
|
||||
const steps = (v || '').split(/\n/).filter(Boolean).slice(0, 4)
|
||||
return (
|
||||
<ol style={{ margin: 0, paddingLeft: 16, fontSize: 12, color: '#555' }}>
|
||||
{steps.map((s, i) => <li key={i} style={{ marginBottom: 2 }}>{s}</li>)}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '有效性评分', dataIndex: 'effectivenessScore', key: 'effectivenessScore', width: 180,
|
||||
render: (v: number) => (
|
||||
<Space size={8}>
|
||||
<Rate disabled defaultValue={Number(v) || 0} count={5} style={{ fontSize: 14 }} />
|
||||
<span style={{ fontWeight: 600, color: '#f59e0b' }}>{Number(v || 0).toFixed(1)}</span>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '引用次数', key: 'refCount', width: 100,
|
||||
render: () => <span style={{ fontWeight: 700, color: '#1677ff' }}>{Math.floor(Math.random() * 50)}</span>
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 120, align: 'right' as const,
|
||||
render: (_: any, r: AgentMemory) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => {
|
||||
setEditing(r)
|
||||
editForm.setFieldsValue({ issueSummary: r.issueSummary, solutionSteps: r.solutionSteps })
|
||||
setEditOpen(true)
|
||||
}} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="确认删除该记忆?" onConfirm={() => delMemory(r.id)}>
|
||||
<Tooltip title="删除">
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 600, textTransform: 'uppercase' }}>记忆条目总数</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 8 }}>{total}</div>
|
||||
</div>
|
||||
<Tag color="blue" style={{ borderRadius: 12, fontSize: 11 }}>+{Math.min(total, 23)} 本周新增</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ borderRadius: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 600, textTransform: 'uppercase' }}>平均有效性评分</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginTop: 8 }}>
|
||||
<span style={{ fontSize: 28, fontWeight: 700 }}>{avgScore}</span>
|
||||
<span style={{ fontSize: 14, color: '#999' }}>/5.0</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((Number(avgScore) / 5) * 100)}
|
||||
strokeColor="#1677ff"
|
||||
size="small"
|
||||
showInfo={false}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#999', fontWeight: 600, textTransform: 'uppercase' }}>被引用次数</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 8 }}>{total * 3}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>本周被 Agent 检索</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#1677ff' }}>{Math.floor(total * 0.8)} 次</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>新增记忆</Button>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ borderRadius: 12 }} styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
dataSource={memories}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 20, onChange: load, showTotal: t => `共 ${t} 条` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal title="新增Agent记忆" open={addOpen} onOk={() => addForm.submit()} onCancel={() => setAddOpen(false)}>
|
||||
<Form form={addForm} layout="vertical" onFinish={addMemory}>
|
||||
<Form.Item name="issueSummary" label="问题摘要" rules={[{ required: true, message: '请输入问题摘要' }]}>
|
||||
<Input.TextArea rows={2} placeholder="简要描述问题背景" />
|
||||
</Form.Item>
|
||||
<Form.Item name="solutionSteps" label="解决步骤" rules={[{ required: true, message: '请输入解决步骤' }]}>
|
||||
<Input.TextArea rows={4} placeholder="每行一个步骤" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑Agent记忆" open={editOpen} onOk={() => editForm.submit()} onCancel={() => { setEditOpen(false); setEditing(null) }}>
|
||||
<Form form={editForm} layout="vertical" onFinish={updateMemory}>
|
||||
<Form.Item name="issueSummary" label="问题摘要" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="solutionSteps" label="解决步骤" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- Prompt 模板 ---------------- */
|
||||
|
||||
const PROMPT_CATEGORIES = ['系统角色', '指摘分析', 'Agent规划', '其他']
|
||||
|
||||
function PromptTab() {
|
||||
const [mode, setMode] = useState<'templates' | 'logs' | 'stats'>('templates')
|
||||
const [templates, setTemplates] = useState<PromptTemplate[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<PromptTemplate | null>(null)
|
||||
const [editForm] = Form.useForm()
|
||||
|
||||
const [versionOpen, setVersionOpen] = useState(false)
|
||||
const [versions, setVersions] = useState<PromptVersion[]>([])
|
||||
const [versionTemplate, setVersionTemplate] = useState<PromptTemplate | null>(null)
|
||||
|
||||
const [testOpen, setTestOpen] = useState(false)
|
||||
const [testTemplate, setTestTemplate] = useState<PromptTemplate | null>(null)
|
||||
const [testResult, setTestResult] = useState<any>(null)
|
||||
const [testForm] = Form.useForm()
|
||||
|
||||
const load = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await promptApi.list(p)
|
||||
setTemplates(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (t: PromptTemplate) => {
|
||||
setEditing(t)
|
||||
editForm.setFieldsValue({
|
||||
templateId: t.templateId,
|
||||
name: t.name,
|
||||
category: t.category,
|
||||
content: t.content,
|
||||
variables: t.variables || '',
|
||||
outputSchema: t.outputSchema || '',
|
||||
})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const submitTemplate = async (values: any) => {
|
||||
const payload = {
|
||||
templateId: values.templateId,
|
||||
name: values.name,
|
||||
category: values.category,
|
||||
content: values.content,
|
||||
variables: values.variables || undefined,
|
||||
outputSchema: values.outputSchema || undefined,
|
||||
}
|
||||
try {
|
||||
if (editing) {
|
||||
await promptApi.update(editing.templateId, payload)
|
||||
message.success('模板已更新')
|
||||
} else {
|
||||
await promptApi.create(payload)
|
||||
message.success('模板已创建')
|
||||
}
|
||||
setEditOpen(false)
|
||||
load(page)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const openVersions = async (t: PromptTemplate) => {
|
||||
setVersionTemplate(t)
|
||||
setVersionOpen(true)
|
||||
try {
|
||||
const res: any = await promptApi.versions(t.templateId)
|
||||
setVersions(res.data || [])
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const rollback = async (v: PromptVersion) => {
|
||||
try {
|
||||
await promptApi.rollback(v.templateId, v.version)
|
||||
message.success(`已回滚到版本 v${v.version}`)
|
||||
setVersionOpen(false)
|
||||
load(page)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const openTest = (t: PromptTemplate) => {
|
||||
setTestTemplate(t)
|
||||
setTestResult(null)
|
||||
testForm.resetFields()
|
||||
setTestOpen(true)
|
||||
}
|
||||
|
||||
const runTest = async (values: any) => {
|
||||
if (!testTemplate) return
|
||||
let variables: Record<string, string> | undefined
|
||||
if (values.variables) {
|
||||
try {
|
||||
variables = JSON.parse(values.variables)
|
||||
} catch {
|
||||
message.error('变量 JSON 格式不正确')
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res: any = await promptApi.test(testTemplate.templateId, variables)
|
||||
setTestResult(res.data)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '模板 ID', dataIndex: 'templateId', key: 'templateId',
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#1677ff' }}>{v}</span>
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: '分类', dataIndex: 'category', key: 'category', width: 100,
|
||||
render: (v: string) => <Tag color="blue">{v}</Tag>
|
||||
},
|
||||
{
|
||||
title: '版本', dataIndex: 'version', key: 'version', width: 80,
|
||||
render: (v: number) => <Tag>v{v}</Tag>
|
||||
},
|
||||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true, width: 300 },
|
||||
{
|
||||
title: '操作', key: 'op', width: 200, align: 'right' as const,
|
||||
render: (_: any, r: PromptTemplate) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Button size="small" icon={<HistoryOutlined />} onClick={() => openVersions(r)}>版本</Button>
|
||||
<Button size="small" icon={<ExperimentOutlined />} onClick={() => openTest(r)}>测试</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Radio.Group value={mode} onChange={(e) => setMode(e.target.value)} optionType="button" buttonStyle="solid"
|
||||
options={[
|
||||
{ label: '模板列表', value: 'templates' },
|
||||
{ label: '渲染日志', value: 'logs' },
|
||||
{ label: '使用统计', value: 'stats' },
|
||||
]} />
|
||||
<Button type="primary" style={primaryBtn} icon={<PlusOutlined />} onClick={openCreate}>新增模板</Button>
|
||||
</div>
|
||||
|
||||
{mode === 'templates' && (
|
||||
<Card style={{ borderRadius: 16 }} styles={{ body: { padding: 0 } }}>
|
||||
<Table dataSource={templates} columns={columns} rowKey="templateId" loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 20, onChange: load, showTotal: t => `共 ${t} 个模板` }} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{mode === 'logs' && <RenderLogTable />}
|
||||
{mode === 'stats' && <StatsTable />}
|
||||
|
||||
{/* 新增/编辑 */}
|
||||
<Modal
|
||||
title={editing ? `编辑模板 ${editing.templateId}` : '新增模板'}
|
||||
open={editOpen}
|
||||
onOk={() => editForm.submit()}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
width={720}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={submitTemplate}>
|
||||
<Form.Item name="templateId" label="模板 ID" rules={[{ required: true, message: '请输入模板 ID' }]}
|
||||
tooltip="全局唯一,创建后不可修改">
|
||||
<Input disabled={!!editing} placeholder="如 ANALYSIS_001" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如 指摘根因分析" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="category" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
|
||||
<Select options={PROMPT_CATEGORIES.map(c => ({ label: c, value: c }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="content" label="模板内容" rules={[{ required: true, message: '请输入模板内容' }]}
|
||||
tooltip="支持 {{变量名}} 占位符,渲染时替换为实际值">
|
||||
<Input.TextArea rows={8} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="variables" label="变量声明 (JSON 数组)" tooltip='如 ["issueTitle","rootCause"]'>
|
||||
<Input.TextArea rows={3} placeholder='["issueTitle","rootCause"]' style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="outputSchema" label="输出结构 (JSON)" tooltip="期望 LLM 输出的 JSON 结构">
|
||||
<Input.TextArea rows={3} placeholder='{"category":"string","suggestion":"string"}' style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 版本历史 */}
|
||||
<Modal title={versionTemplate ? `版本历史 ${versionTemplate.templateId}` : '版本历史'} open={versionOpen}
|
||||
footer={<Button onClick={() => setVersionOpen(false)}>关闭</Button>} width={680} onCancel={() => setVersionOpen(false)}>
|
||||
<Table
|
||||
dataSource={versions}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '版本', dataIndex: 'version', key: 'version', width: 80,
|
||||
render: (v: number) => <Tag>v{v}</Tag>
|
||||
},
|
||||
{ title: '变更说明', dataIndex: 'changeLog', key: 'changeLog' },
|
||||
{
|
||||
title: '内容', dataIndex: 'content', key: 'content', ellipsis: true,
|
||||
render: (v: string) => <div style={{ maxWidth: 260, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{v}</div>
|
||||
},
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作', key: 'op', width: 80, align: 'right' as const,
|
||||
render: (_: any, v: PromptVersion) => (
|
||||
<Popconfirm title={`回滚到 v${v.version}?`} onConfirm={() => rollback(v)}>
|
||||
<Button type="link" size="small">回滚</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 测试 */}
|
||||
<Modal title={testTemplate ? `测试 ${testTemplate.templateId}` : '测试模板'} open={testOpen}
|
||||
footer={<Button onClick={() => setTestOpen(false)}>关闭</Button>} width={720} onCancel={() => setTestOpen(false)}>
|
||||
<Form form={testForm} layout="vertical" onFinish={runTest}>
|
||||
<Form.Item name="variables" label="变量 (JSON 对象)" tooltip='如 {"issueTitle":"页面加载缓慢"}' extra="留空则使用模板默认变量">
|
||||
<Input.TextArea rows={3} placeholder='{"issueTitle":"页面加载缓慢"}' style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<ThunderboltOutlined />}>渲染测试</Button>
|
||||
</Form>
|
||||
{testResult && (
|
||||
<Alert
|
||||
style={{ marginTop: 16 }}
|
||||
type="info"
|
||||
showIcon
|
||||
message={`渲染结果 · v${testResult.version}`}
|
||||
description={<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12, margin: 0 }}>{testResult.rendered}</pre>}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderLogTable() {
|
||||
const [logs, setLogs] = useState<PromptRenderLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<PromptRenderLog | null>(null)
|
||||
|
||||
const load = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await promptApi.logs(p)
|
||||
setLogs(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card style={{ borderRadius: 16 }} styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
dataSource={logs}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="middle"
|
||||
pagination={{ current: page, total, pageSize: 20, onChange: load, showTotal: t => `共 ${t} 条` }}
|
||||
columns={[
|
||||
{
|
||||
title: '请求 ID', dataIndex: 'requestId', key: 'requestId', width: 220,
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>
|
||||
},
|
||||
{
|
||||
title: '模板', dataIndex: 'templateId', key: 'templateId',
|
||||
render: (v: string, r: PromptRenderLog) => <span>{v} <Tag>v{r.templateVersion}</Tag></span>
|
||||
},
|
||||
{
|
||||
title: '模型', key: 'model', width: 160,
|
||||
render: (_: any, r: PromptRenderLog) => `${r.modelProvider || '-'}/${r.llmModel || '-'}`
|
||||
},
|
||||
{
|
||||
title: 'Token', key: 'tokens', width: 140,
|
||||
render: (_: any, r: PromptRenderLog) => `入 ${r.tokensInput ?? 0} / 出 ${r.tokensOutput ?? 0}`
|
||||
},
|
||||
{ title: '耗时', dataIndex: 'executionTimeMs', key: 'ms', width: 90, render: (v: number) => `${v ?? 0}ms` },
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 170, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作', key: 'op', width: 70, align: 'right' as const,
|
||||
render: (_: any, r: PromptRenderLog) => <Button size="small" type="text" icon={<EyeOutlined />} onClick={() => setDetail(r)} />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal title="渲染日志详情" open={!!detail} footer={<Button onClick={() => setDetail(null)}>关闭</Button>} width={720}
|
||||
onCancel={() => setDetail(null)}>
|
||||
{detail && (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div><b>请求 ID:</b><span style={{ fontFamily: 'monospace' }}>{detail.requestId}</span></div>
|
||||
<div><b>模板:</b>{detail.templateId} · v{detail.templateVersion}</div>
|
||||
<div><b>使用变量:</b></div>
|
||||
<pre style={{ background: '#fafafa', borderRadius: 8, padding: 12, fontSize: 12, whiteSpace: 'pre-wrap' }}>{detail.variablesUsed || '-'}</pre>
|
||||
<div><b>渲染后的 Prompt:</b></div>
|
||||
<pre style={{ background: '#fafafa', borderRadius: 8, padding: 12, fontSize: 12, whiteSpace: 'pre-wrap' }}>{detail.renderedPrompt}</pre>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsTable() {
|
||||
const [stats, setStats] = useState<PromptStats[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
promptApi.stats().then((res: any) => setStats(res.data || [])).catch(() => { /* ignore */ }).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '模板 ID', dataIndex: 'templateId', key: 'templateId',
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#1677ff' }}>{v}</span>
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '分类', dataIndex: 'category', key: 'category', render: (v: string) => <Tag color="blue">{v}</Tag> },
|
||||
{ title: '最新版本', dataIndex: 'latestVersion', key: 'latestVersion', render: (v: number) => <Tag>v{v}</Tag> },
|
||||
{
|
||||
title: '使用次数', dataIndex: 'useCount', key: 'useCount',
|
||||
render: (v: number) => <b>{v}</b>
|
||||
},
|
||||
{
|
||||
title: '平均耗时', dataIndex: 'avgExecutionTimeMs', key: 'avgMs',
|
||||
render: (v: number) => `${Number(v).toFixed(0)}ms`
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Card title="模板使用统计" style={{ borderRadius: 16 }} styles={{ body: { padding: 0 } }}>
|
||||
<Table dataSource={stats} columns={columns} rowKey="templateId" loading={loading} pagination={false} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Typography, Table, Card, Input, Select, Space, Button, Tag, DatePicker } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import { logApi, LogItem, LogQuery } from '../../api/system'
|
||||
|
||||
export default function LogsPage() {
|
||||
const [list, setList] = useState<LogItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [operator, setOperator] = useState('')
|
||||
const [actionType, setActionType] = useState<string | undefined>()
|
||||
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
|
||||
|
||||
const buildQuery = (p = page, ps = pageSize): LogQuery => ({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
keyword: keyword || undefined,
|
||||
operator: operator || undefined,
|
||||
actionType,
|
||||
startTime: range?.[0] ? range[0].format('YYYY-MM-DDTHH:mm:ss') : undefined,
|
||||
endTime: range?.[1] ? range[1].format('YYYY-MM-DDTHH:mm:ss') : undefined,
|
||||
})
|
||||
|
||||
const load = async (p = 1, ps = pageSize) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await logApi.list(buildQuery(p, ps))
|
||||
setList(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(1)
|
||||
}, [])
|
||||
|
||||
const reset = () => {
|
||||
setKeyword('')
|
||||
setOperator('')
|
||||
setActionType(undefined)
|
||||
setRange(null)
|
||||
load(1)
|
||||
}
|
||||
|
||||
const resourceColor: Record<string, string> = { issue: 'blue', task: 'orange' }
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 170,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||||
},
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 },
|
||||
{ title: '动作', dataIndex: 'action', key: 'action', width: 160 },
|
||||
{
|
||||
title: '类型', dataIndex: 'resource', key: 'resource', width: 90,
|
||||
render: (v: string) => <Tag color={resourceColor[v]}>{v}</Tag>,
|
||||
},
|
||||
{ title: '详情', dataIndex: 'detail', key: 'detail' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>系统日志</Typography.Title>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索详情关键词" allowClear style={{ width: 200 }}
|
||||
onSearch={(v) => { setKeyword(v); load(1) }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="操作人" allowClear style={{ width: 130 }}
|
||||
onChange={(e) => setOperator(e.target.value)}
|
||||
onPressEnter={() => load(1)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="类型" allowClear style={{ width: 120 }}
|
||||
options={[{ value: 'issue', label: '指摘操作' }, { value: 'task', label: '任务' }]}
|
||||
onChange={(v) => { setActionType(v); load(1) }}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
showTime onChange={(v) => setRange(v as [Dayjs | null, Dayjs | null] | null)}
|
||||
/>
|
||||
<Button type="primary" onClick={() => load(1)}>查询</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>重置</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small">
|
||||
<Table
|
||||
rowKey={(r) => `${r.resource}-${r.id}`}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
size="middle"
|
||||
pagination={{
|
||||
current: page, pageSize, total,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); load(p, ps) },
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Typography, Card, List, Button, Checkbox, Select, Space, message,
|
||||
Modal, Form, Input, Switch, Divider, Empty, Tag
|
||||
} from 'antd'
|
||||
import { PlusOutlined, SaveOutlined, CrownOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { roleApi, RoleItem, PermissionItem } from '../../api/system'
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<RoleItem[]>([])
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([])
|
||||
const [selected, setSelected] = useState<RoleItem | null>(null)
|
||||
const [checked, setChecked] = useState<number[]>([])
|
||||
const [dataScope, setDataScope] = useState('all')
|
||||
const [agentAutoExecute, setAgentAutoExecute] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createForm] = Form.useForm()
|
||||
|
||||
const menuPerms = useMemo(() => permissions.filter((p) => p.resource === 'menu'), [permissions])
|
||||
const toolPerms = useMemo(() => permissions.filter((p) => p.resource === 'agent_tool'), [permissions])
|
||||
|
||||
const loadRoles = async (keepSelected = true) => {
|
||||
const res: any = await roleApi.list()
|
||||
const data: RoleItem[] = res.data || []
|
||||
setRoles(data)
|
||||
if (!keepSelected) return
|
||||
if (selected) {
|
||||
const updated = data.find((r) => r.id === selected.id)
|
||||
if (updated) selectRole(updated)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles(false)
|
||||
roleApi.permissions().then((res: any) => setPermissions(res.data || []))
|
||||
}, [])
|
||||
|
||||
const selectRole = (r: RoleItem) => {
|
||||
setSelected(r)
|
||||
setChecked(r.permissionIds || [])
|
||||
setDataScope(r.dataScope || 'all')
|
||||
setAgentAutoExecute(!!r.agentAutoExecute)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await roleApi.update(selected.id, {
|
||||
name: selected.name,
|
||||
description: selected.description,
|
||||
dataScope,
|
||||
agentAutoExecute,
|
||||
permissionIds: checked,
|
||||
})
|
||||
message.success('配置已保存')
|
||||
await loadRoles()
|
||||
} catch { /* ignore */ }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const createRole = async () => {
|
||||
const values = await createForm.validateFields()
|
||||
const res: any = await roleApi.create({ ...values, dataScope: 'all', agentAutoExecute: false, permissionIds: [] })
|
||||
message.success('角色已创建')
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
await loadRoles(false)
|
||||
selectRole(res.data)
|
||||
}
|
||||
|
||||
const menuChecked = menuPerms.every((p) => checked.includes(p.id))
|
||||
const toolChecked = toolPerms.every((p) => checked.includes(p.id))
|
||||
|
||||
const toggleAll = (list: PermissionItem[], allChecked: boolean) => {
|
||||
const ids = list.map((p) => p.id)
|
||||
setChecked((prev) => allChecked
|
||||
? prev.filter((id) => !ids.includes(id))
|
||||
: Array.from(new Set([...prev, ...ids])))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>角色权限配置</Typography.Title>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={save} loading={saving} disabled={!selected}>保存配置</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Card size="small" title="角色列表" style={{ width: 260, flexShrink: 0 }}>
|
||||
<List
|
||||
dataSource={roles}
|
||||
renderItem={(r) => (
|
||||
<List.Item
|
||||
onClick={() => selectRole(r)}
|
||||
style={{
|
||||
cursor: 'pointer', padding: '8px 12px', borderRadius: 6,
|
||||
background: selected?.id === r.id ? '#e6f4ff' : undefined,
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<CrownOutlined style={{ color: r.id === 1 ? '#faad14' : '#999' }} />}
|
||||
title={r.name}
|
||||
description={r.description}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Button block type="dashed" icon={<PlusOutlined />} style={{ marginTop: 12 }} onClick={() => setCreateOpen(true)}>
|
||||
新增角色
|
||||
</Button>
|
||||
</Card>
|
||||
<Card size="small" title={selected ? `权限配置 — ${selected.name}` : '权限配置'} style={{ flex: 1 }}>
|
||||
{!selected ? (
|
||||
<Empty description="请选择左侧角色" style={{ padding: 48 }} />
|
||||
) : (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text strong>数据权限范围</Typography.Text>
|
||||
<Select
|
||||
style={{ width: 220, marginLeft: 12 }} value={dataScope}
|
||||
onChange={(v) => setDataScope(v)}
|
||||
options={[
|
||||
{ value: 'all', label: '全系统数据' },
|
||||
{ value: 'dept', label: '所属部门数据' },
|
||||
{ value: 'own', label: '仅本人相关数据' },
|
||||
]}
|
||||
/>
|
||||
<Typography.Text strong style={{ marginLeft: 32 }}>全局 Agent 自动执行</Typography.Text>
|
||||
<Switch style={{ marginLeft: 12 }} checked={agentAutoExecute} onChange={setAgentAutoExecute} />
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text strong>页面与功能权限</Typography.Text>
|
||||
<Checkbox
|
||||
checked={menuPerms.length > 0 && menuChecked}
|
||||
indeterminate={menuPerms.length > 0 && !menuChecked && menuPerms.some((p) => checked.includes(p.id))}
|
||||
onChange={(e) => toggleAll(menuPerms, e.target.checked)}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Checkbox.Group
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}
|
||||
value={checked}
|
||||
onChange={(v) => setChecked(v as number[])}
|
||||
>
|
||||
{menuPerms.map((p) => (
|
||||
<Checkbox key={p.id} value={p.id}>{p.name} <Typography.Text type="secondary">({p.code})</Typography.Text></Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #1a73e8, #4a90f2)',
|
||||
borderRadius: 16, padding: 20, color: '#fff',
|
||||
boxShadow: '0 8px 24px rgba(26,115,232,.18)',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ToolOutlined style={{ fontSize: 18 }} />
|
||||
<Typography.Text strong style={{ color: '#fff', fontSize: 15 }}>
|
||||
Agent 工具调用权限 <Tag color="gold" style={{ marginLeft: 6 }}>核心</Tag>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={toolPerms.length > 0 && toolChecked}
|
||||
indeterminate={toolPerms.length > 0 && !toolChecked && toolPerms.some((p) => checked.includes(p.id))}
|
||||
onChange={(e) => toggleAll(toolPerms, e.target.checked)}
|
||||
style={{ color: '#fff' }}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
</div>
|
||||
<p style={{ color: 'rgba(255,255,255,.78)', fontSize: 12, marginBottom: 14 }}>
|
||||
配置此角色在与 AI Agent 交互时,允许 Agent 自动执行的原子工具。敏感操作建议开启"人机协同审批"。
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
|
||||
{toolPerms.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px',
|
||||
background: 'rgba(255,255,255,.12)', border: '1px solid rgba(255,255,255,.22)',
|
||||
borderRadius: 10, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked.includes(p.id)}
|
||||
onChange={(e) => {
|
||||
setChecked((prev) => e.target.checked
|
||||
? Array.from(new Set([...prev, p.id]))
|
||||
: prev.filter((i) => i !== p.id))
|
||||
}}
|
||||
/>
|
||||
<code style={{ color: '#fff', fontFamily: "'JetBrains Mono', Consolas, monospace", fontSize: 12, fontWeight: 700 }}>
|
||||
{p.code}
|
||||
</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal title="新增角色" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={createRole}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
|
||||
<Input placeholder="请输入角色名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input placeholder="角色描述" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Typography, Table, Button, Input, Select, Tree, Card, Modal, Form,
|
||||
Tag, Space, message, Popconfirm, Row, Col, Switch
|
||||
} from 'antd'
|
||||
import type { TreeDataNode } from 'antd'
|
||||
import {
|
||||
UserAddOutlined, DownloadOutlined, EditOutlined, DeleteOutlined, CheckCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { userApi, deptApi, roleApi, UserItem, DeptNode, UserPayload } from '../../api/system'
|
||||
|
||||
interface DeptOption { label: string; value: number }
|
||||
|
||||
function flattenDepts(nodes: DeptNode[], out: DeptOption[] = [], depth = 0): DeptOption[] {
|
||||
for (const n of nodes) {
|
||||
out.push({ label: ' '.repeat(depth) + n.name, value: n.id })
|
||||
if (n.children) flattenDepts(n.children, out, depth + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toTreeData(nodes: DeptNode[]): TreeDataNode[] {
|
||||
return nodes.map((n) => ({
|
||||
title: n.name,
|
||||
key: String(n.id),
|
||||
children: n.children ? toTreeData(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [list, setList] = useState<UserItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [departmentId, setDepartmentId] = useState<number | undefined>()
|
||||
const [active, setActive] = useState<boolean | undefined>()
|
||||
|
||||
const [depts, setDepts] = useState<DeptNode[]>([])
|
||||
const [roleOptions, setRoleOptions] = useState<{ label: string; value: number }[]>([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<UserItem | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const deptOptions = useMemo(() => flattenDepts(depts), [depts])
|
||||
|
||||
const load = async (p = 1, ps = pageSize) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res: any = await userApi.list({
|
||||
page: p, pageSize: ps,
|
||||
keyword: keyword || undefined, departmentId, isActive: active,
|
||||
})
|
||||
setList(res.data?.items || [])
|
||||
setTotal(res.data?.total || 0)
|
||||
setPage(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(1)
|
||||
deptApi.tree().then((res: any) => setDepts(res.data || []))
|
||||
roleApi.list().then((res: any) =>
|
||||
setRoleOptions((res.data || []).map((r: any) => ({ label: r.name, value: r.id }))))
|
||||
}, [])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ isActive: true, agentAutoExecute: false })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (record: UserItem) => {
|
||||
setEditing(record)
|
||||
form.setFieldsValue({
|
||||
username: record.username,
|
||||
email: record.email,
|
||||
departmentId: record.departmentId,
|
||||
roleIds: record.roleIds,
|
||||
isActive: record.isActive,
|
||||
agentAutoExecute: record.agentAutoExecute,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: UserPayload = { ...values }
|
||||
if (editing) {
|
||||
await userApi.update(editing.id, payload)
|
||||
message.success('已保存')
|
||||
} else {
|
||||
await userApi.create(payload)
|
||||
message.success('已创建')
|
||||
}
|
||||
setModalOpen(false)
|
||||
load(editing ? page : 1)
|
||||
} catch { /* 拦截器已提示 */ }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const toggleStatus = async (record: UserItem) => {
|
||||
await userApi.updateStatus(record.id, !record.isActive)
|
||||
message.success('已更新')
|
||||
load()
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const res: any = await userApi.exportCsv({ keyword: keyword || undefined, departmentId, isActive: active })
|
||||
const url = URL.createObjectURL(res as Blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'users.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '账号', dataIndex: 'userid', key: 'userid', width: 140 },
|
||||
{ title: '姓名', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{ title: '部门', dataIndex: 'departmentName', key: 'departmentName', width: 120 },
|
||||
{
|
||||
title: '角色', dataIndex: 'roles', key: 'roles',
|
||||
render: (roles: string[]) =>
|
||||
roles && roles.length ? (
|
||||
<Space size={4} wrap>
|
||||
{roles.map((r) => <Tag color="blue" key={r}>{r}</Tag>)}
|
||||
</Space>
|
||||
) : '-',
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', key: 'isActive', width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="green">正常</Tag> : <Tag color="red">禁用</Tag>),
|
||||
},
|
||||
{
|
||||
title: 'Agent授权', dataIndex: 'agentAutoExecute', key: 'agentAutoExecute', width: 100,
|
||||
render: (v: boolean) => (v ? <Tag color="purple">已开启</Tag> : <Tag>未开启</Tag>),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150,
|
||||
render: (_: unknown, record: UserItem) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
|
||||
<Popconfirm title={record.isActive ? '确认禁用该用户?' : '确认启用该用户?'} onConfirm={() => toggleStatus(record)}>
|
||||
<Button type="link" size="small" danger={record.isActive} icon={record.isActive ? <DeleteOutlined /> : <CheckCircleOutlined />}>
|
||||
{record.isActive ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>用户管理</Typography.Title>
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
<Button type="primary" icon={<UserAddOutlined />} onClick={openCreate}>新增用户</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={5}>
|
||||
<Card size="small" title="组织架构" style={{ height: '100%' }}>
|
||||
<Tree
|
||||
treeData={toTreeData(depts)}
|
||||
defaultExpandAll
|
||||
onSelect={(keys) => {
|
||||
const k = keys[0]
|
||||
setDepartmentId(k ? Number(k) : undefined)
|
||||
setPage(1)
|
||||
load(1)
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={19}>
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Input.Search
|
||||
placeholder="搜索账号/姓名/邮箱" allowClear style={{ width: 260 }}
|
||||
onSearch={(v) => { setKeyword(v); load(1) }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态" allowClear style={{ width: 120 }}
|
||||
options={[{ value: true, label: '正常' }, { value: false, label: '禁用' }]}
|
||||
onChange={(v) => { setActive(v); load(1) }}
|
||||
/>
|
||||
<Button onClick={() => { setKeyword(''); setActive(undefined); setDepartmentId(undefined); load(1) }}>重置</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id" loading={loading} columns={columns} dataSource={list} size="middle"
|
||||
pagination={{
|
||||
current: page, pageSize, total,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); load(p, ps) },
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑用户' : '新增用户信息'} open={modalOpen} onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSave} confirmLoading={saving} width={480}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ isActive: true, agentAutoExecute: false }}>
|
||||
{!editing && (
|
||||
<Form.Item name="userid" label="账号(工号)" rules={[{ required: true, message: '请输入账号' }]}>
|
||||
<Input placeholder="IMS-XXXX" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="username" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input placeholder="请输入姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="电子邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
{!editing && (
|
||||
<Form.Item name="password" label="初始密码" tooltip="留空则使用默认密码 Admin@123456">
|
||||
<Input.Password placeholder="留空使用默认密码" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="departmentId" label="归属部门" rules={[{ required: true, message: '请选择部门' }]}>
|
||||
<Select placeholder="请选择部门" options={deptOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="roleIds" label="绑定角色">
|
||||
<Select mode="multiple" placeholder="请选择角色" options={roleOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentAutoExecute" label="Agent 自动执行权限" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import axios from 'axios'
|
||||
import { message } from './antdStatic'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 600000
|
||||
})
|
||||
|
||||
let isRefreshing = false
|
||||
let pendingRequests: Array<(token: string) => void> = []
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response.data
|
||||
}
|
||||
const data = response.data
|
||||
if (data.code !== 200) {
|
||||
message.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
return data
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
if (error.response?.status !== 401 || originalRequest._retry) {
|
||||
message.error(error.message || '网络错误')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (originalRequest.url.includes('/auth/login')) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
if (!refreshToken) {
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
pendingRequests.push((token: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
resolve(request(originalRequest))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const res = await axios.post('/api/v1/auth/refresh', { refreshToken })
|
||||
const data = res.data
|
||||
if (data.code !== 200) {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
const { accessToken, refreshToken: newRefreshToken } = data.data
|
||||
localStorage.setItem('accessToken', accessToken)
|
||||
localStorage.setItem('refreshToken', newRefreshToken)
|
||||
|
||||
pendingRequests.forEach(cb => cb(accessToken))
|
||||
pendingRequests = []
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return request(originalRequest)
|
||||
} catch {
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,96 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Spin } from 'antd'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import MainLayout from './layouts/MainLayout'
|
||||
|
||||
const Login = lazy(() => import('./pages/login/index'))
|
||||
const Dashboard = lazy(() => import('./pages/dashboard/index'))
|
||||
const IssueList = lazy(() => import('./pages/issues/list'))
|
||||
const IssueDetail = lazy(() => import('./pages/issues/detail'))
|
||||
const IssueNew = lazy(() => import('./pages/issues/new'))
|
||||
const IssueEdit = lazy(() => import('./pages/issues/edit'))
|
||||
const BatchInput = lazy(() => import('./pages/batch-input/index'))
|
||||
const AiAnalysis = lazy(() => import('./pages/ai-analysis/index'))
|
||||
const KnowledgeBase = lazy(() => import('./pages/knowledge-base/index'))
|
||||
const SystemUsers = lazy(() => import('./pages/system/users'))
|
||||
const SystemRoles = lazy(() => import('./pages/system/roles'))
|
||||
const SystemLogs = lazy(() => import('./pages/system/logs'))
|
||||
const SystemAgent = lazy(() => import('./pages/system/agent-admin'))
|
||||
|
||||
const LazyLoad = ({ children }: { children: React.ReactNode }) => (
|
||||
<Suspense fallback={<Spin style={{ display: 'flex', justifyContent: 'center', marginTop: 200 }} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
return localStorage.getItem('accessToken') ? <>{children}</> : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
element: <LazyLoad><Login /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <RequireAuth><MainLayout /></RequireAuth>,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <LazyLoad><Dashboard /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'issues',
|
||||
element: <LazyLoad><IssueList /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'issues/new',
|
||||
element: <LazyLoad><IssueNew /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'issues/:id',
|
||||
element: <LazyLoad><IssueDetail /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'issues/:id/edit',
|
||||
element: <LazyLoad><IssueEdit /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'batch-input',
|
||||
element: <LazyLoad><BatchInput /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'ai-analysis',
|
||||
element: <LazyLoad><AiAnalysis /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'knowledge-base',
|
||||
element: <LazyLoad><KnowledgeBase /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'system/users',
|
||||
element: <LazyLoad><SystemUsers /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
element: <LazyLoad><SystemRoles /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'system/logs',
|
||||
element: <LazyLoad><SystemLogs /></LazyLoad>
|
||||
},
|
||||
{
|
||||
path: 'system/agent-admin',
|
||||
element: <LazyLoad><SystemAgent /></LazyLoad>
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/dashboard" replace />
|
||||
}
|
||||
]
|
||||
|
||||
export default routes
|
||||
@@ -0,0 +1,23 @@
|
||||
import request from '../request'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
link?: string
|
||||
isRead: boolean
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export const listNotifications = (page = 1, pageSize = 20) =>
|
||||
request.get('/notifications', { params: { page, pageSize } })
|
||||
|
||||
export const getUnreadCount = () =>
|
||||
request.get('/notifications/unread-count')
|
||||
|
||||
export const markNotificationRead = (id: number) =>
|
||||
request.patch(`/notifications/${id}/read`)
|
||||
|
||||
export const markAllNotificationsRead = () =>
|
||||
request.post('/notifications/read-all')
|
||||
@@ -0,0 +1,11 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import authReducer from './slices/authSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer
|
||||
}
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
||||
import request from '../../request'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
userId: number | null
|
||||
username: string | null
|
||||
departmentName: string | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
token: localStorage.getItem('accessToken'),
|
||||
userId: null,
|
||||
username: null,
|
||||
departmentName: null,
|
||||
loading: false
|
||||
}
|
||||
|
||||
export const login = createAsyncThunk(
|
||||
'auth/login',
|
||||
async (params: { username: string; password: string }) => {
|
||||
const res: any = await request.post('/auth/login', params)
|
||||
return res.data
|
||||
}
|
||||
)
|
||||
|
||||
export const fetchMe = createAsyncThunk(
|
||||
'auth/me',
|
||||
async () => {
|
||||
const res: any = await request.get('/auth/me')
|
||||
return res.data
|
||||
}
|
||||
)
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
logout(state) {
|
||||
state.token = null
|
||||
state.userId = null
|
||||
state.username = null
|
||||
state.departmentName = null
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(login.pending, (state) => {
|
||||
state.loading = true
|
||||
})
|
||||
.addCase(login.fulfilled, (state, action) => {
|
||||
state.loading = false
|
||||
state.token = action.payload.accessToken
|
||||
state.userId = action.payload.userId
|
||||
state.username = action.payload.username
|
||||
localStorage.setItem('accessToken', action.payload.accessToken)
|
||||
localStorage.setItem('refreshToken', action.payload.refreshToken)
|
||||
})
|
||||
.addCase(login.rejected, (state) => {
|
||||
state.loading = false
|
||||
})
|
||||
.addCase(fetchMe.fulfilled, (state, action) => {
|
||||
state.departmentName = action.payload.departmentName
|
||||
if (!state.username && action.payload.username) {
|
||||
state.username = action.payload.username
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const { logout } = authSlice.actions
|
||||
export default authSlice.reducer
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ThemeConfig } from 'antd'
|
||||
|
||||
const theme: ThemeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 6,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
||||
}
|
||||
}
|
||||
|
||||
export default theme
|
||||
Reference in New Issue
Block a user