Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kne-components/components-admin",
"version": "1.1.38",
"version": "1.1.39",
"description": "用于实现一个后台管理系统的必要组件",
"scripts": {
"init": "husky",
Expand Down
9 changes: 5 additions & 4 deletions src/components/Task/Dashboard/HistorySection.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
PALETTE, RANGE_OPTIONS, TASK_STATUS_LIST, STATUS_COLOR_MAP, TASK_TYPE_COLOR_MAP,
tooltipStyle, legendCenterStyle,
lineChartGrid, lineChartGridWithRotatedLabels, lineSmooth,
axisLineStyle, axisLabelStyle, splitLineStyle, formatDuration
axisLineStyle, axisLabelStyle, splitLineStyle, formatDuration, sanitizeStatisticsDurationMs
} from './constants';
import SectionHeader from './SectionHeader';
import { getClientIanaTimezone } from '../utils';
import style from './dashboard.module.scss';

const HistorySection = createWithRemoteLoader({
Expand Down Expand Up @@ -47,7 +48,7 @@ const HistorySection = createWithRemoteLoader({

<Fetch
{...Object.assign({}, apis.task.statistics.getOverview, {
params: { range }
params: { range, timezone: getClientIanaTimezone() }
})}
render={({ data, reload }) => {
reloadRef.current = reload;
Expand Down Expand Up @@ -194,8 +195,8 @@ const HistorySection = createWithRemoteLoader({
const buildWaitExecDurationOption = rows => {
if (!rows || rows.length === 0) return null;
const dates = rows.map(item => item.date);
const avgWait = rows.map(item => item.avgWaitingTime || 0);
const avgExec = rows.map(item => item.avgExecutionTime || 0);
const avgWait = rows.map(item => sanitizeStatisticsDurationMs(item.avgWaitingTime) ?? 0);
const avgExec = rows.map(item => sanitizeStatisticsDurationMs(item.avgExecutionTime) ?? 0);
const manyPoints = dates.length > 14;
return {
color: [PALETTE.waiting, PALETTE.running],
Expand Down
131 changes: 81 additions & 50 deletions src/components/Task/Dashboard/RealtimeSection.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createWithRemoteLoader } from '@kne/remote-loader';
import { useMemo } from 'react';
import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Col, Row, Space, Tag } from 'antd';
import { BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { Card as BoxCard, ColorfulCard } from '@kne/react-box';
Expand All @@ -17,7 +18,9 @@ import {
axisLineStyle,
axisLabelStyle,
splitLineStyle,
formatDuration
formatDuration,
pickStatisticsDurationMs,
sanitizeStatisticsDurationMs
} from './constants';
import useRealtimeStatisticsSSE from './useRealtimeStatisticsSSE';
import style from './dashboard.module.scss';
Expand All @@ -43,42 +46,40 @@ const buildTodayHourlySlots = raw => {
slots[h].byType[item.type] = (slots[h].byType[item.type] || 0) + n;
});

const hasByTypeHourly = Object.values(slots).some(item => Object.keys(item.byType).length > 0);
// 兼容后端只返回 records 的场景:从 records 反推每小时按类型统计
if (!hasByTypeHourly && Array.isArray(raw?.records) && raw.records.length > 0) {
raw.records.forEach(record => {
const createdAt = record?.createdAt ? new Date(record.createdAt) : null;
if (!createdAt || Number.isNaN(createdAt.getTime())) return;
const h = createdAt.getHours();
if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return;
const typeKey = String(record?.type);
slots[h].byType[typeKey] = (slots[h].byType[typeKey] || 0) + 1;
if (!slots[h].hasTotalFromApi) {
slots[h].total += 1;
}
});
}

return slots;
};

const hasHourlyInput = raw => {
const a = raw?.hourlyTrend;
const b = raw?.hourlyTrendByType;
const r = raw?.records;
return (
(Array.isArray(a) && a.length > 0) ||
(Array.isArray(b) && b.length > 0) ||
(Array.isArray(r) && r.length > 0)
);
return (Array.isArray(a) && a.length > 0) || (Array.isArray(b) && b.length > 0);
};

const RealtimeSection = createWithRemoteLoader({
modules: ['components-thirdparty:Echart', 'components-core:Enum']
})(
withLocale(({ remoteModules, apis }) => {
withLocale(({ remoteModules, apis, baseUrl }) => {
const [Echart, Enum] = remoteModules;
const { formatMessage } = useIntl();
const navigate = useNavigate();
const myTaskPath = useMemo(() => {
if (baseUrl == null || baseUrl === '') return null;
const prefix = String(baseUrl).replace(/\/$/, '');
return `${prefix}/task/my`;
}, [baseUrl]);
const goMyTasks = useCallback(() => {
if (myTaskPath) navigate(myTaskPath);
}, [navigate, myTaskPath]);
const manualPanelKeyHandler = useCallback(
e => {
if (!myTaskPath) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goMyTasks();
}
},
[goMyTasks, myTaskPath]
);
const sseUrl = apis?.task?.statistics?.sse?.url;
const { realtimeData, isConnected, lastUpdatedAt } = useRealtimeStatisticsSSE(sseUrl);

Expand All @@ -90,23 +91,25 @@ const RealtimeSection = createWithRemoteLoader({
const runningCount = Number(byStatus.running) || 0;
const waitingCount = Number(byStatus.waiting) || 0;
const canceledCount = Number(byStatus.canceled) || 0;
const manualTaskCount = Number(byRunnerType.manual) || 0;
const manualPendingCount = useMemo(() => {
const records = Array.isArray(realtimeData?.records) ? realtimeData.records : [];
return records.reduce((acc, item) => {
const isManual = String(item?.runnerType || '') === 'manual';
const isPending = String(item?.status || '') === 'pending';
return isManual && isPending ? acc + 1 : acc;
}, 0);
}, [realtimeData]);
const manualExecutedCount = Math.max(0, manualTaskCount - manualPendingCount);
const manualRt = realtimeData?.runnerTypeStats?.manual;
const hasManualRunnerStats = manualRt && typeof manualRt === 'object';
const pendingByRunnerType = realtimeData?.pendingByRunnerType || {};
const manualTaskCount = hasManualRunnerStats
? Number(manualRt.total) || 0
: Number(byRunnerType.manual) || 0;
const manualPendingCount = hasManualRunnerStats
? Number(manualRt.pending) || 0
: Number(pendingByRunnerType.manual) || 0;
const manualExecutedCount = hasManualRunnerStats
? Number(manualRt.executed) || 0
: Math.max(0, manualTaskCount - (Number(pendingByRunnerType.manual) || 0));
const manualDurationDisplay = useMemo(() => {
const manualDur = realtimeData?.todayDuration?.byRunnerType?.manual;
if (!manualDur || typeof manualDur !== 'object') return null;
return {
avgWaitingTime: formatDuration(manualDur.avgWaitingTime),
avgExecutionTime: formatDuration(manualDur.avgExecutionTime),
avgTotalTime: formatDuration(manualDur.avgTotalTime)
avgWaitingTime: formatDuration(sanitizeStatisticsDurationMs(manualDur.avgWaitingTime)),
avgExecutionTime: formatDuration(sanitizeStatisticsDurationMs(manualDur.avgExecutionTime)),
avgTotalTime: formatDuration(sanitizeStatisticsDurationMs(manualDur.avgTotalTime))
};
}, [realtimeData]);

Expand Down Expand Up @@ -228,17 +231,39 @@ const RealtimeSection = createWithRemoteLoader({
const dur = realtimeData?.todayDuration;
if (!dur) return null;
return {
avgWaitingTime: formatDuration(dur.avgWaitingTime),
avgExecutionTime: formatDuration(dur.avgExecutionTime),
avgTotalTime: formatDuration(dur.avgTotalTime)
avgWaitingTime: formatDuration(pickStatisticsDurationMs(dur, 'avgWaitingTime')),
avgExecutionTime: formatDuration(pickStatisticsDurationMs(dur, 'avgExecutionTime')),
avgTotalTime: formatDuration(pickStatisticsDurationMs(dur, 'avgTotalTime'))
};
}, [realtimeData]);

const durationByTypeOption = useMemo(() => {
const byType = realtimeData?.todayDuration?.byType;
if (!byType || typeof byType !== 'object') return null;
const entries = Object.entries(byType).filter(([, value]) => value && typeof value === 'object');
if (entries.length === 0) return null;
const todayDur = realtimeData?.todayDuration;
if (!todayDur || typeof todayDur !== 'object') return null;

const durationByType =
todayDur.byType && typeof todayDur.byType === 'object' ? todayDur.byType : {};
const countByType =
realtimeData.byType && typeof realtimeData.byType === 'object' ? realtimeData.byType : {};

const typeSet = new Set([
...Object.keys(countByType).filter(k => (Number(countByType[k]) || 0) > 0),
...Object.keys(durationByType).filter(k => durationByType[k] && typeof durationByType[k] === 'object')
]);
if (typeSet.size === 0) return null;

const entries = Array.from(typeSet)
.map(type => {
const d = durationByType[type];
if (d && typeof d === 'object') return [type, d];
const c = Number(countByType[type]) || 0;
return [type, { count: c }];
})
.sort((a, b) => {
const ca = Number(a[1]?.count) || Number(countByType[a[0]]) || 0;
const cb = Number(b[1]?.count) || Number(countByType[b[0]]) || 0;
return cb - ca;
});

return {
color: [PALETTE.running, PALETTE.waiting, PALETTE.total],
Expand Down Expand Up @@ -277,21 +302,21 @@ const RealtimeSection = createWithRemoteLoader({
{
name: formatMessage({ id: 'AvgExecutionTime' }),
type: 'bar',
data: entries.map(([, value]) => Number(value.avgExecutionTime) || 0),
data: entries.map(([, value]) => sanitizeStatisticsDurationMs(value.avgExecutionTime) ?? 0),
barMaxWidth: 28,
itemStyle: { color: PALETTE.running, borderRadius: [4, 4, 0, 0] }
},
{
name: formatMessage({ id: 'AvgWaitingTime' }),
type: 'bar',
data: entries.map(([, value]) => Number(value.avgWaitingTime) || 0),
data: entries.map(([, value]) => sanitizeStatisticsDurationMs(value.avgWaitingTime) ?? 0),
barMaxWidth: 28,
itemStyle: { color: PALETTE.waiting, borderRadius: [4, 4, 0, 0] }
},
{
name: formatMessage({ id: 'AvgTotalTime' }),
type: 'bar',
data: entries.map(([, value]) => Number(value.avgTotalTime) || 0),
data: entries.map(([, value]) => sanitizeStatisticsDurationMs(value.avgTotalTime) ?? 0),
barMaxWidth: 28,
itemStyle: { color: PALETTE.total, borderRadius: [4, 4, 0, 0] }
}
Expand Down Expand Up @@ -368,10 +393,16 @@ const RealtimeSection = createWithRemoteLoader({

return (
<>
<div className={style.manualExecutionPanel}>
<div
className={`${style.manualExecutionPanel}${myTaskPath ? ` ${style.manualExecutionPanelClickable}` : ''}`}
onClick={myTaskPath ? goMyTasks : undefined}
onKeyDown={myTaskPath ? manualPanelKeyHandler : undefined}
role={myTaskPath ? 'button' : undefined}
tabIndex={myTaskPath ? 0 : undefined}
title={myTaskPath ? formatMessage({ id: 'ManualExecutionGoMyTaskTitle' }) : undefined}
>
<div className={style.manualExecutionHeader}>
<span className={style.manualExecutionTitle}>{formatMessage({ id: 'ManualExecutionStats' })}</span>
<strong className={style.manualExecutionValue}>{manualPendingCount}</strong>
</div>
<div className={`${style.kpiRow} ${style.kpiRowDense}`}>
<ColorfulCard
Expand Down
35 changes: 35 additions & 0 deletions src/components/Task/Dashboard/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,41 @@ export const axisLineStyle = { lineStyle: { color: '#e2e8f0' } };
export const axisLabelStyle = { color: '#94a3b8' };
export const splitLineStyle = { lineStyle: { color: '#f1f5f9', type: 'dashed', width: 1 } };

/** 接口约定为毫秒;超出此上限视为异常数据(如错误聚合),不参与展示与加权回算 */
export const MAX_STATISTICS_DURATION_MS = 7 * 24 * 60 * 60 * 1000;

export const sanitizeStatisticsDurationMs = value => {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return null;
if (n > MAX_STATISTICS_DURATION_MS) return null;
return n;
};

/**
* 优先使用容器顶层字段(经 sanitize);无效时按 count 对 byType、byRunnerType 做加权回算(毫秒)。
*/
export const pickStatisticsDurationMs = (container, field, breakdownKeys = ['byType', 'byRunnerType']) => {
const direct = sanitizeStatisticsDurationMs(container?.[field]);
if (direct != null) return direct;
for (const key of breakdownKeys) {
const obj = container?.[key];
if (!obj || typeof obj !== 'object') continue;
let sum = 0;
let w = 0;
for (const v of Object.values(obj)) {
if (!v || typeof v !== 'object') continue;
const c = Number(v.count) || 0;
if (c <= 0) continue;
const x = sanitizeStatisticsDurationMs(v[field]);
if (x == null) continue;
sum += x * c;
w += c;
}
if (w > 0) return sum / w;
}
return null;
};

export const formatDuration = ms => {
if (ms == null || !Number.isFinite(ms) || ms < 0) return '-';
if (ms === 0) return '0ms';
Expand Down
25 changes: 16 additions & 9 deletions src/components/Task/Dashboard/dashboard.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,24 @@
border-radius: 10px;
}

.manualExecutionPanelClickable {
cursor: pointer;
transition: box-shadow 0.15s ease, border-color 0.15s ease;

&:hover {
border-color: rgba(239, 68, 68, 0.38);
box-shadow: 0 6px 18px rgba(239, 68, 68, 0.12);
}

&:focus-visible {
outline: 2px solid rgba(185, 28, 28, 0.45);
outline-offset: 2px;
}
}

.manualExecutionHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}

Expand All @@ -166,13 +180,6 @@
letter-spacing: 0.02em;
}

.manualExecutionValue {
font-size: 1.2rem;
color: #dc2626;
font-weight: 750;
font-variant-numeric: tabular-nums;
}

.manualPendingCard {
border: 1px solid rgba(239, 68, 68, 0.28);
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.1);
Expand Down
2 changes: 1 addition & 1 deletion src/components/Task/Dashboard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const Dashboard = createWithRemoteLoader({
children={
<div className={style.dashboardRoot}>
<div className={`${style.sectionPanel} ${style.realtimeSectionWrap}`}>
<RealtimeSection apis={apis} />
<RealtimeSection apis={apis} baseUrl={baseUrl} />
</div>
<Divider className={style.sectionDivider} />
<div className={style.sectionPanel}>
Expand Down
13 changes: 4 additions & 9 deletions src/components/Task/Dashboard/useRealtimeStatisticsSSE.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { useEffect, useState } from 'react';
import { getToken } from '@kne/token-storage';
import { buildUrlWithParams } from './constants';

const getClientIanaTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
return '';
}
};
import { getClientIanaTimezone } from '../utils';

const isLikelyTaskStatisticsPayload = obj =>
obj &&
Expand All @@ -19,7 +12,9 @@ const isLikelyTaskStatisticsPayload = obj =>
'hourlyTrend' in obj ||
'hourlyTrendByStatus' in obj ||
'byType' in obj ||
'todayDuration' in obj);
'todayDuration' in obj ||
'pendingByRunnerType' in obj ||
'runnerTypeStats' in obj);

const unwrapStatisticsPayload = parsed => {
if (parsed == null) return null;
Expand Down
1 change: 1 addition & 0 deletions src/components/Task/locale/en-US.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const locale = {
AvgTotalTime: 'Avg Total Time',
RealtimeTaskOverview: 'Task Execution Overview',
ManualExecutionStats: 'Manual Execution Tasks',
ManualExecutionGoMyTaskTitle: 'Click here to open My Tasks',
ManualExecutionTasks: 'Manual Task Count',
ManualPendingTasks: 'Manual Pending Count',
ManualExecutedTasks: 'Manual Executed Count',
Expand Down
1 change: 1 addition & 0 deletions src/components/Task/locale/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const locale = {
AvgTotalTime: '平均总耗时',
RealtimeTaskOverview: '任务执行概览',
ManualExecutionStats: '手动执行任务',
ManualExecutionGoMyTaskTitle: '点击此处查看我的任务',
ManualExecutionTasks: '手动执行数量',
ManualPendingTasks: '手动待执行数',
ManualExecutedTasks: '手动已执行数',
Expand Down
8 changes: 8 additions & 0 deletions src/components/Task/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** 浏览器 IANA 时区,传给 statistics 接口与 SSE,与后端「今日」划界一致 */
export const getClientIanaTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
return '';
}
};
Loading
Loading