fix(ui): correct per-order execution time calculation in report analysis

Fix bug where chart displayed accumulated total execution time instead of per-order average. The executionTimeSecs field now correctly shows average time per order (total time / total orders) for proper performance metrics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-03-26 10:32:41 +08:00
parent 44483120a5
commit 5ff5e5d18b

View File

@@ -41,11 +41,26 @@ interface DailyMetrics {
retriedOrders: number retriedOrders: number
successfulRetries: number successfulRetries: number
executionTimeSecs: number executionTimeSecs: number
avgExecutionTimeSecs: number
users: string[] // Unique users who ran reports on this day users: string[] // Unique users who ran reports on this day
reportCount: number reportCount: number
} }
type MetricKey = keyof Omit<DailyMetrics, 'date' | 'users' | 'reportCount'> // User-specific daily metrics for comparison view
interface UserDailyMetrics {
date: string
user: string
processedOrders: number
deletedMaterials: number
skippedMaterials: number
errors: number
retriedOrders: number
successfulRetries: number
executionTimeSecs: number
reportCount: number
}
type MetricKey = keyof Omit<DailyMetrics, 'date' | 'users' | 'reportCount' | 'avgExecutionTimeSecs'>
const METRIC_LABELS: Record<MetricKey, string> = { const METRIC_LABELS: Record<MetricKey, string> = {
processedOrders: '处理订单数', processedOrders: '处理订单数',
@@ -54,7 +69,7 @@ const METRIC_LABELS: Record<MetricKey, string> = {
errors: '错误数量', errors: '错误数量',
retriedOrders: '重试订单数', retriedOrders: '重试订单数',
successfulRetries: '成功重试数', successfulRetries: '成功重试数',
executionTimeSecs: '执行耗时(秒)' executionTimeSecs: '每订单平均耗时(秒)'
} }
const METRIC_COLORS: Record<MetricKey, string> = { const METRIC_COLORS: Record<MetricKey, string> = {
@@ -64,7 +79,24 @@ const METRIC_COLORS: Record<MetricKey, string> = {
errors: '#000000', // black errors: '#000000', // black
retriedOrders: '#8b5cf6', // violet-500 retriedOrders: '#8b5cf6', // violet-500
successfulRetries: '#10b981', // emerald-500 successfulRetries: '#10b981', // emerald-500
executionTimeSecs: '#64748b' // slate-500 executionTimeSecs: '#f97316' // orange-500
}
// User colors for comparison view
const USER_COLORS = [
'#3b82f6', // blue-500
'#10b981', // emerald-500
'#f59e0b', // amber-500
'#ef4444', // red-500
'#8b5cf6', // violet-500
'#ec4899', // pink-500
'#06b6d4', // cyan-500
'#84cc16' // lime-500
]
const getUserColor = (user: string, users: string[]): string => {
const index = users.indexOf(user)
return USER_COLORS[index % USER_COLORS.length]
} }
export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
@@ -81,6 +113,12 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
new Set(['processedOrders', 'deletedMaterials', 'errors']) new Set(['processedOrders', 'deletedMaterials', 'errors'])
) )
// View mode: aggregated (by date) or comparison (by user)
const [viewMode, setViewMode] = useState<'aggregated' | 'comparison'>('aggregated')
// Selected users for comparison view
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set())
const parseDurationToSeconds = (durationStr: string): number => { const parseDurationToSeconds = (durationStr: string): number => {
// Handle empty or zero case // Handle empty or zero case
if (!durationStr || durationStr === '0秒' || durationStr === '0分0秒') { if (!durationStr || durationStr === '0秒' || durationStr === '0分0秒') {
@@ -265,6 +303,7 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
retriedOrders: 0, retriedOrders: 0,
successfulRetries: 0, successfulRetries: 0,
executionTimeSecs: 0, executionTimeSecs: 0,
avgExecutionTimeSecs: 0,
users: [], users: [],
reportCount: 0 reportCount: 0
}) })
@@ -285,6 +324,16 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
} }
} }
// Calculate average execution time per order for each day
// Formula: total execution time / total processed orders (efficiency metric)
for (const day of dailyMap.values()) {
day.avgExecutionTimeSecs = day.processedOrders > 0
? day.executionTimeSecs / day.processedOrders
: 0
// Replace executionTimeSecs with avgExecutionTimeSecs for chart display
day.executionTimeSecs = day.avgExecutionTimeSecs
}
// Convert map to array and sort by date // Convert map to array and sort by date
const sortedData = Array.from(dailyMap.values()).sort((a, b) => { const sortedData = Array.from(dailyMap.values()).sort((a, b) => {
// Basic string comparison works for YYYY-MM-DD // Basic string comparison works for YYYY-MM-DD
@@ -294,6 +343,99 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
return sortedData return sortedData
}, [reportData]) }, [reportData])
// Extract all unique users from report data
const allUsers = useMemo(() => {
const userSet = new Set<string>()
reportData.forEach(data => userSet.add(data.user))
return Array.from(userSet).sort()
}, [reportData])
// Aggregate data by date AND user for comparison view
const comparisonData = useMemo(() => {
if (!reportData.length) return []
// Filter by selected users if any
const filteredData = selectedUsers.size > 0
? reportData.filter(data => selectedUsers.has(data.user))
: reportData
// Group by date + user
const keyMap = new Map<string, UserDailyMetrics>()
for (const data of filteredData) {
const key = `${data.date}|${data.user}`
if (!keyMap.has(key)) {
keyMap.set(key, {
date: data.date,
user: data.user,
processedOrders: 0,
deletedMaterials: 0,
skippedMaterials: 0,
errors: 0,
retriedOrders: 0,
successfulRetries: 0,
executionTimeSecs: 0,
reportCount: 0
})
}
const entry = keyMap.get(key)!
entry.processedOrders += data.processedOrders
entry.deletedMaterials += data.deletedMaterials
entry.skippedMaterials += data.skippedMaterials
entry.errors += data.errors
entry.retriedOrders += data.retriedOrders
entry.successfulRetries += data.successfulRetries
entry.executionTimeSecs += data.executionTimeSecs
entry.reportCount += 1
}
// Calculate average execution time per order for each entry
// Formula: total execution time / total processed orders (efficiency metric)
for (const entry of keyMap.values()) {
entry.executionTimeSecs = entry.processedOrders > 0
? entry.executionTimeSecs / entry.processedOrders
: 0
}
return Array.from(keyMap.values())
.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.user.localeCompare(b.user)
})
}, [reportData, selectedUsers])
// Format comparison data for chart rendering
const comparisonChartData = useMemo(() => {
if (!comparisonData.length) return []
const dates = [...new Set(comparisonData.map(d => d.date))].sort()
const users = [...new Set(comparisonData.map(d => d.user))]
.filter(user => selectedUsers.size === 0 || selectedUsers.has(user))
.sort()
const lookup = new Map<string, UserDailyMetrics>()
comparisonData.forEach(d => {
lookup.set(`${d.date}|${d.user}`, d)
})
return dates.map(date => {
const point: any = { date }
users.forEach(user => {
const key = `${date}|${user}`
const data = lookup.get(key)
Array.from(selectedMetrics).forEach(metric => {
const userKey = `${user}_${metric}` as any
point[userKey] = data ? (data as any)[metric] : 0
})
})
return point
})
}, [comparisonData, selectedMetrics, selectedUsers])
const handleMetricToggle = (metric: MetricKey) => { const handleMetricToggle = (metric: MetricKey) => {
const next = new Set(selectedMetrics) const next = new Set(selectedMetrics)
if (next.has(metric)) { if (next.has(metric)) {
@@ -340,6 +482,7 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
<div className="mt-3 pt-2 border-t border-slate-100 text-xs text-slate-500"> <div className="mt-3 pt-2 border-t border-slate-100 text-xs text-slate-500">
<p>: {dailyData.users.join(', ')}</p> <p>: {dailyData.users.join(', ')}</p>
<p className="mt-1">: {dailyData.reportCount}</p> <p className="mt-1">: {dailyData.reportCount}</p>
<p className="mt-1">: {dailyData.avgExecutionTimeSecs.toFixed(1)} </p>
</div> </div>
)} )}
</div> </div>
@@ -348,6 +491,40 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
return null return null
} }
// Comparison Tooltip for user-specific data
const ComparisonTooltip = ({ active, payload, label, users, selectedUsers }: any) => {
if (active && payload && payload.length) {
const displayUsers = selectedUsers.size === 0 ? users : Array.from(selectedUsers)
const firstMetric = Array.from(selectedMetrics)[0]
return (
<div className="bg-white p-4 border border-slate-200 shadow-lg rounded-lg max-w-sm">
<p className="font-semibold text-slate-800 mb-2 border-b border-slate-100 pb-2">{label}</p>
<div className="space-y-1.5 text-sm">
{displayUsers.map((user: string) => {
const userEntry = payload.find((p: any) => p.name === (user || '未分配'))
if (!userEntry) return null
return (
<div key={user} className="flex justify-between items-center gap-4">
<span className="flex items-center gap-1.5 text-slate-600">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: userEntry.color }} />
{user || '未分配'}:
</span>
<span className="font-medium text-slate-900">
{userEntry.value} {firstMetric === 'executionTimeSecs' ? '秒' : ''}
</span>
</div>
)
})}
</div>
</div>
)
}
return null
}
if (!isOpen) return null if (!isOpen) return null
if (!isAdmin) return null if (!isAdmin) return null
@@ -424,8 +601,99 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
</div> </div>
</div> </div>
{/* View Mode Toggle */}
<div className="mb-6">
<h3 className="text-sm font-medium text-slate-700 mb-3"></h3>
<div className="flex gap-2">
<button
onClick={() => setViewMode('aggregated')}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
viewMode === 'aggregated'
? 'bg-blue-50 border-blue-200 text-blue-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
</button>
<button
onClick={() => setViewMode('comparison')}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
viewMode === 'comparison'
? 'bg-blue-50 border-blue-200 text-blue-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
</button>
</div>
</div>
{/* User Filter Chips - Only in comparison mode */}
{viewMode === 'comparison' && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-slate-700"></h3>
<div className="flex gap-2">
<button
onClick={() => setSelectedUsers(new Set(allUsers))}
className="text-xs text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setSelectedUsers(new Set())}
className="text-xs text-slate-500 hover:underline"
>
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{allUsers.map((user) => {
const isSelected = selectedUsers.has(user)
const color = getUserColor(user, allUsers)
return (
<button
key={user}
onClick={() => {
const next = new Set(selectedUsers)
if (next.has(user)) {
next.delete(user)
} else {
next.add(user)
}
setSelectedUsers(next)
}}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors flex items-center gap-1.5 ${
isSelected
? 'bg-white border-current'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
style={isSelected ? { color, borderColor: color } : {}}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: isSelected ? color : '#cbd5e1' }}
/>
{user || '未分配'}
</button>
)
})}
</div>
{selectedUsers.size === 0 && (
<p className="text-xs text-slate-500 mt-2">
</p>
)}
</div>
)}
{/* Chart */} {/* Chart */}
<div className="flex-1 min-h-[400px]"> <div className="flex-1 min-h-[400px]">
{viewMode === 'aggregated' ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart <ComposedChart
data={chartData} data={chartData}
@@ -447,7 +715,6 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ paddingTop: '20px' }} iconType="circle" /> <Legend wrapperStyle={{ paddingTop: '20px' }} iconType="circle" />
{/* Render selected metrics as lines or bars */}
{Array.from(selectedMetrics).map((metric) => ( {Array.from(selectedMetrics).map((metric) => (
<Line <Line
key={metric} key={metric}
@@ -462,10 +729,71 @@ export const ReportAnalysisDialog: React.FC<ReportAnalysisDialogProps> = ({
))} ))}
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={comparisonChartData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 12 }}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 12 }}
/>
<Tooltip content={<ComparisonTooltip users={allUsers} selectedUsers={selectedUsers} />} />
<Legend wrapperStyle={{ paddingTop: '20px' }} iconType="circle" />
{selectedUsers.size === 0 || selectedUsers.size > 1
? // Multiple users: show first metric for each user
allUsers.filter(user => selectedUsers.size === 0 || selectedUsers.has(user)).map((user) => (
<Line
key={user}
type="monotone"
dataKey={`${user}_${Array.from(selectedMetrics)[0]}`}
name={user || '未分配'}
stroke={getUserColor(user, allUsers)}
strokeWidth={2}
dot={{ r: 4, strokeWidth: 2 }}
activeDot={{ r: 6, strokeWidth: 0 }}
/>
))
: // Single user: show all metrics for that user
Array.from(selectedMetrics).map((metric) => {
const user = Array.from(selectedUsers)[0]
return (
<Line
key={metric}
type="monotone"
dataKey={`${user}_${metric}`}
name={METRIC_LABELS[metric]}
stroke={METRIC_COLORS[metric]}
strokeWidth={2}
dot={{ r: 4, strokeWidth: 2 }}
activeDot={{ r: 6, strokeWidth: 0 }}
/>
)
})
}
</ComposedChart>
</ResponsiveContainer>
)}
</div> </div>
<div className="mt-4 text-center text-xs text-slate-400"> <div className="mt-4 text-center text-xs text-slate-400">
{viewMode === 'aggregated'
? '数据以天为单位进行聚合统计。展示的是选定时间段内的总量。'
: selectedUsers.size === 0
? '展示所有用户的数据对比。未选择用户时显示全部。'
: '展示选定用户的数据对比。'
}
</div> </div>
</div> </div>
)} )}