init: 2026Technology-Competition initial commit

This commit is contained in:
xunhe
2026-08-27 17:35:01 +08:00
commit e2a4d40c9e
311 changed files with 37037 additions and 0 deletions
+9
View File
@@ -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
+17
View File
@@ -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 }
+327
View File
@@ -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'),
}
+59
View File
@@ -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: '产品部' }
]
+188
View File
@@ -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>
)
}
+25
View File
@@ -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>
)
+508
View File
@@ -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>
)
}
+162
View File
@@ -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>
)
}
+166
View File
@@ -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>
)
}
+600
View File
@@ -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>
)
}
+317
View File
@@ -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>
)
}
+50
View File
@@ -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')
+285
View File
@@ -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>
)
})
+504
View File
@@ -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>
)
}
+196
View File
@@ -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>
)
}
+327
View File
@@ -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>
)
}
+71
View File
@@ -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>
)
}
+164
View File
@@ -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 })
+253
View File
@@ -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'),
}
+41
View File
@@ -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'),
}
+1582
View File
@@ -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>
)
}
+109
View File
@@ -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>
)
}
+222
View File
@@ -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>
)
}
+247
View File
@@ -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>
)
}
+92
View File
@@ -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
+96
View File
@@ -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
+23
View File
@@ -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')
+11
View File
@@ -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
+75
View File
@@ -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
+11
View File
@@ -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