feat(ui): replace Steps and Column chart with custom timeline and cards in ExecutionProcess
This commit is contained in:
parent
61d8b8b4b8
commit
81741a67e7
@ -3,10 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import type { ExecutionProgress, IntelligentEval } from '../../api'
|
import type { ExecutionProgress, IntelligentEval } from '../../api'
|
||||||
import ExecutionProcess from './ExecutionProcess'
|
import ExecutionProcess from './ExecutionProcess'
|
||||||
|
|
||||||
vi.mock('@ant-design/charts', () => ({
|
|
||||||
Column: () => <div data-testid="slot-chart" />,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const getExecutionProgress = vi.fn()
|
const getExecutionProgress = vi.fn()
|
||||||
const listDecisionLogs = vi.fn()
|
const listDecisionLogs = vi.fn()
|
||||||
const listTasks = vi.fn()
|
const listTasks = vi.fn()
|
||||||
@ -93,11 +89,12 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ExecutionProcess', () => {
|
describe('ExecutionProcess', () => {
|
||||||
it('renders lifecycle steps, next action and slot chart', async () => {
|
it('renders lifecycle timeline, next action and time distribution cards', async () => {
|
||||||
mockApi()
|
mockApi()
|
||||||
render(<ExecutionProcess ev={makeEval()} />)
|
render(<ExecutionProcess ev={makeEval()} />)
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByTestId('slot-chart')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('0-1h')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('1-2h')).toBeInTheDocument()
|
||||||
expect(screen.getByText('规划')).toBeInTheDocument()
|
expect(screen.getByText('规划')).toBeInTheDocument()
|
||||||
expect(screen.getByText('审批')).toBeInTheDocument()
|
expect(screen.getByText('审批')).toBeInTheDocument()
|
||||||
expect(screen.getByText(/下一步:等待平台触发 worker 补足当前欠账 2 个会话/)).toBeInTheDocument()
|
expect(screen.getByText(/下一步:等待平台触发 worker 补足当前欠账 2 个会话/)).toBeInTheDocument()
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Alert, Card, Empty, Spin, Steps, Tag, Timeline } from 'antd'
|
import { Alert, Card, Empty, Spin, Tag, Timeline } from 'antd'
|
||||||
import { Column } from '@ant-design/charts'
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
intelligentEvalsApi,
|
intelligentEvalsApi,
|
||||||
type DecisionLog,
|
type DecisionLog,
|
||||||
@ -15,11 +21,11 @@ import { usePolling } from '../../hooks/usePolling'
|
|||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(['planning', 'pending_approval', 'executing'])
|
const ACTIVE_STATUSES = new Set(['planning', 'pending_approval', 'executing'])
|
||||||
|
|
||||||
const STAGE_STEPS = [
|
const STAGE_CONFIG = [
|
||||||
{ key: 'planning', title: '规划' },
|
{ key: 'planning', title: '规划', icon: EditOutlined },
|
||||||
{ key: 'approval', title: '审批' },
|
{ key: 'approval', title: '审批', icon: FileTextOutlined },
|
||||||
{ key: 'executing', title: '执行' },
|
{ key: 'executing', title: '执行', icon: PlayCircleOutlined },
|
||||||
{ key: 'done', title: '完成' },
|
{ key: 'done', title: '完成', icon: CheckCircleOutlined },
|
||||||
]
|
]
|
||||||
|
|
||||||
const DECISION_TYPE_LABELS: Record<string, string> = {
|
const DECISION_TYPE_LABELS: Record<string, string> = {
|
||||||
@ -91,6 +97,136 @@ function decisionEvents(logs: DecisionLog[]): ActivityEvent[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LifecycleTimeline({ currentStage, abnormal }: { currentStage: string; abnormal: boolean }) {
|
||||||
|
const currentIndex = STAGE_CONFIG.findIndex((s) => s.key === currentStage)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 0' }}>
|
||||||
|
{STAGE_CONFIG.map((stage, idx) => {
|
||||||
|
const Icon = stage.icon
|
||||||
|
const isCompleted = idx < currentIndex
|
||||||
|
const isCurrent = idx === currentIndex
|
||||||
|
const isError = isCurrent && abnormal
|
||||||
|
|
||||||
|
const bgColor = isError
|
||||||
|
? statusColors.failed
|
||||||
|
: isCompleted
|
||||||
|
? statusColors.completed
|
||||||
|
: isCurrent
|
||||||
|
? colors.primary
|
||||||
|
: colors.bgSubtle
|
||||||
|
|
||||||
|
const textColor = isCompleted || isCurrent ? '#fff' : colors.textSecondary
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stage.key} style={{ display: 'flex', alignItems: 'center', flex: idx < STAGE_CONFIG.length - 1 ? 1 : 'none' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: bgColor,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 18,
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
boxShadow: isCurrent ? `0 0 0 3px ${bgColor}33` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCompleted ? <CheckCircleOutlined /> : <Icon />}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: isCurrent ? colors.primary : colors.textSecondary, fontWeight: isCurrent ? 600 : 400 }}>
|
||||||
|
{stage.title}
|
||||||
|
{isCurrent && <div style={{ fontSize: 11, color: colors.textMuted, marginTop: 2 }}>当前阶段</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{idx < STAGE_CONFIG.length - 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 2,
|
||||||
|
background: isCompleted ? statusColors.completed : colors.border,
|
||||||
|
margin: '0 8px',
|
||||||
|
marginBottom: 28,
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeDistributionCards({ slots }: { slots: ExecutionProgress['slots'] }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||||
|
{slots.map((slot) => {
|
||||||
|
const plannedPct = slot.planned > 0 ? 100 : 0
|
||||||
|
const createdPct = slot.planned > 0 ? (slot.created / slot.planned) * 100 : 0
|
||||||
|
const completedPct = slot.planned > 0 ? (slot.completed / slot.planned) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={slot.time_slot}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
border: slot.is_current ? `2px solid ${colors.primary}` : `1px solid ${colors.border}`,
|
||||||
|
background: slot.is_current ? `${colors.primary}08` : colors.bgContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{slot.time_slot}</div>
|
||||||
|
{slot.is_current && (
|
||||||
|
<Tag color={colors.primary} style={{ fontSize: 11, margin: 0 }}>
|
||||||
|
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||||
|
当前
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: colors.textSecondary, marginBottom: 4 }}>
|
||||||
|
<span>计划</span>
|
||||||
|
<span>{slot.planned}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, background: colors.bgSubtle, borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${plannedPct}%`, height: '100%', background: colors.textMuted, borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: colors.textSecondary, marginBottom: 4 }}>
|
||||||
|
<span>已创建</span>
|
||||||
|
<span>{slot.created}/{slot.planned}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, background: colors.bgSubtle, borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${createdPct}%`, height: '100%', background: colors.primary, borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: colors.textSecondary, marginBottom: 4 }}>
|
||||||
|
<span>已完成</span>
|
||||||
|
<span>{slot.completed}/{slot.planned}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, background: colors.bgSubtle, borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${completedPct}%`, height: '100%', background: statusColors.completed, borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
||||||
const [progress, setProgress] = useState<ExecutionProgress | null>(null)
|
const [progress, setProgress] = useState<ExecutionProgress | null>(null)
|
||||||
const [logs, setLogs] = useState<DecisionLog[]>([])
|
const [logs, setLogs] = useState<DecisionLog[]>([])
|
||||||
@ -132,42 +268,12 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
|||||||
return <Empty description="执行过程数据不可用" style={{ padding: 48 }} />
|
return <Empty description="执行过程数据不可用" style={{ padding: 48 }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const stageIndex = STAGE_STEPS.findIndex((s) => s.key === progress.current_stage)
|
|
||||||
const abnormal = progress.abnormal_outcome != null
|
const abnormal = progress.abnormal_outcome != null
|
||||||
|
|
||||||
const slotData = progress.slots.flatMap((s) => {
|
|
||||||
const label = s.is_current ? `${s.time_slot}(当前)` : s.time_slot
|
|
||||||
return [
|
|
||||||
{ slot: label, kind: '计划', value: s.planned },
|
|
||||||
{ slot: label, kind: '已创建', value: s.created },
|
|
||||||
{ slot: label, kind: '已完成', value: s.completed },
|
|
||||||
]
|
|
||||||
})
|
|
||||||
const slotConfig = {
|
|
||||||
data: slotData,
|
|
||||||
xField: 'slot',
|
|
||||||
yField: 'value',
|
|
||||||
seriesField: 'kind',
|
|
||||||
group: true,
|
|
||||||
height: Math.max(200, progress.slots.length * 72),
|
|
||||||
color: [colors.textMuted, colors.primary, statusColors.completed],
|
|
||||||
legend: { position: 'top' },
|
|
||||||
xAxis: { label: { autoRotate: true } },
|
|
||||||
yAxis: { min: 0 },
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', padding: '16px 16px 24px' }}>
|
<div style={{ height: '100%', overflowY: 'auto', padding: '16px 16px 24px' }}>
|
||||||
<Card size="small" title="生命周期" style={{ marginBottom: 16 }}>
|
<Card size="small" title="生命周期" style={{ marginBottom: 16 }}>
|
||||||
<Steps
|
<LifecycleTimeline currentStage={progress.current_stage} abnormal={abnormal} />
|
||||||
current={stageIndex < 0 ? 0 : stageIndex}
|
|
||||||
status={abnormal ? 'error' : progress.current_stage === 'done' ? 'finish' : 'process'}
|
|
||||||
size="small"
|
|
||||||
items={STAGE_STEPS.map((s) => ({
|
|
||||||
title: s.title,
|
|
||||||
description: s.key === progress.current_stage ? '当前阶段' : undefined,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
{progress.blocker != null && (
|
{progress.blocker != null && (
|
||||||
<Alert
|
<Alert
|
||||||
type={abnormal ? 'error' : 'warning'}
|
type={abnormal ? 'error' : 'warning'}
|
||||||
@ -190,7 +296,7 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
|||||||
{progress.slots.length === 0 ? (
|
{progress.slots.length === 0 ? (
|
||||||
<Empty description="暂无时段分布(粗计划未提交或评估未开始执行)" />
|
<Empty description="暂无时段分布(粗计划未提交或评估未开始执行)" />
|
||||||
) : (
|
) : (
|
||||||
<Column {...slotConfig} />
|
<TimeDistributionCards slots={progress.slots} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user