diff --git a/package.json b/package.json
index d37c9a6..4f88228 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@kne-components/components-admin",
- "version": "1.1.39",
+ "version": "1.1.40",
"description": "用于实现一个后台管理系统的必要组件",
"scripts": {
"init": "husky",
diff --git a/src/components/Apis/getApis.js b/src/components/Apis/getApis.js
index 572287f..652e811 100644
--- a/src/components/Apis/getApis.js
+++ b/src/components/Apis/getApis.js
@@ -164,10 +164,22 @@ const getApis = options => {
method: 'POST'
},
statistics: {
+ /**
+ * 历史统计看板(GET)。
+ * Query:`range`(7d|1m|1y)、`timezone`(IANA,与「今日」划界一致)。
+ * 响应字段约定见 `src/components/Task/doc/api.md`「任务统计 HTTP」。
+ */
getOverview: {
url: `${prefix}/task/statistics`,
method: 'GET'
},
+ /**
+ * 实时统计(Server-Sent Events,GET)。
+ * Query:`interval`、`token`、`timezone`(见 `useRealtimeStatisticsSSE` 与 Task `getClientIanaTimezone`)。
+ * 每条 `message` 的 `data` 为 JSON 对象(或 `{ data: {...} }` 包裹),字段约定见 `src/components/Task/doc/api.md`「任务实时统计 SSE」。
+ * 看板手动区:条数用 `waitingByRunnerType.manual` / `completedToday.manual`;主时长用
+ * `waitingQueueMaxWaitMsByRunnerType.manual`(队列最长等待)、`completedTodayTotalDurationMsByRunnerType.manual`(当日完成创建→完成之和)。
+ */
sse: {
url: `${prefix}/task/statistics/sse`,
method: 'GET'
diff --git a/src/components/MessageManger/Dashboard/HistorySection.js b/src/components/MessageManger/Dashboard/HistorySection.js
index 1ea0461..3698f57 100644
--- a/src/components/MessageManger/Dashboard/HistorySection.js
+++ b/src/components/MessageManger/Dashboard/HistorySection.js
@@ -26,6 +26,24 @@ import SectionHeader from './SectionHeader';
import { getClientIanaTimezone } from '../utils';
import style from './dashboard.module.scss';
+const RANGE_DAY_COUNT = { '7d': 7, '1m': 30, '1y': 365 };
+
+/** 本地日历上从 range 起点到今天的日期轴(YYYY-MM-DD) */
+const buildLocalDateAxisForRange = rangeKey => {
+ const days = RANGE_DAY_COUNT[rangeKey] || 7;
+ const dates = [];
+ for (let i = days - 1; i >= 0; i -= 1) {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ d.setDate(d.getDate() - i);
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ dates.push(`${y}-${m}-${day}`);
+ }
+ return dates;
+};
+
const HistorySection = createWithRemoteLoader({
modules: ['components-thirdparty:Echart']
})(
@@ -70,12 +88,18 @@ const HistorySection = createWithRemoteLoader({
const trendOption = (() => {
const recentTrend = data?.recentTrend || [];
const recentTrendByType = data?.recentTrendByType || [];
- if (recentTrend.length === 0) return null;
+ const axisDates = recentTrend.length > 0 ? null : buildLocalDateAxisForRange(range);
const dateMap = {};
- recentTrend.forEach(item => {
- dateMap[item.date] = { date: item.date, total: item.count };
- });
+ if (recentTrend.length > 0) {
+ recentTrend.forEach(item => {
+ dateMap[item.date] = { date: item.date, total: item.count };
+ });
+ } else {
+ axisDates.forEach(d => {
+ dateMap[d] = { date: d, total: 0 };
+ });
+ }
recentTrendByType.forEach(item => {
if (!dateMap[item.date]) {
dateMap[item.date] = { date: item.date, total: 0 };
@@ -155,7 +179,32 @@ const HistorySection = createWithRemoteLoader({
const codePieOption = (() => {
const byCode = data?.byCode || {};
const entries = Object.entries(byCode);
- if (entries.length === 0) return null;
+ if (entries.length === 0) {
+ return {
+ tooltip: { show: false },
+ legend: { show: false },
+ graphic: [
+ {
+ type: 'text',
+ left: 'center',
+ top: 'center',
+ style: {
+ text: formatMessage({ id: 'NoData' }),
+ fill: '#94a3b8',
+ fontSize: 14,
+ fontWeight: 500
+ }
+ }
+ ],
+ series: [
+ {
+ ...pieSeries(['50%', '42%'], ['44%', '62%']),
+ silent: true,
+ data: [{ value: 1, name: '', itemStyle: { color: '#f1f5f9' }, label: { show: false } }]
+ }
+ ]
+ };
+ }
return {
color: PALETTE.pie,
tooltip: itemTooltipStyle,
@@ -232,11 +281,7 @@ const HistorySection = createWithRemoteLoader({
- {trendOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
+
@@ -247,11 +292,7 @@ const HistorySection = createWithRemoteLoader({
- {codePieOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
+
diff --git a/src/components/MessageManger/Dashboard/RealtimeSection.js b/src/components/MessageManger/Dashboard/RealtimeSection.js
index 243aeb2..82f5ccd 100644
--- a/src/components/MessageManger/Dashboard/RealtimeSection.js
+++ b/src/components/MessageManger/Dashboard/RealtimeSection.js
@@ -49,6 +49,7 @@ const buildTodayHourlySlots = raw => {
if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return;
const n = Number(item?.count) || 0;
const typ = Number(item?.type);
+ if (!Number.isFinite(typ)) return;
if (typ === 0) slots[h].email += n;
else if (typ === 1) slots[h].sms += n;
});
@@ -79,15 +80,17 @@ const buildTodayHourlySlots = raw => {
return slots;
};
-const hasHourlyInput = raw => {
- const records = raw?.records;
- const a = raw?.hourlyTrend;
- const b = raw?.hourlyTrendByType;
- return (
- (Array.isArray(records) && records.length > 0) ||
- (Array.isArray(a) && a.length > 0) ||
- (Array.isArray(b) && b.length > 0)
- );
+/** 消息实时统计:type 0 邮件、1 短信(与 enums messageManagerType 一致) */
+const messageTypeLabel = (typeKey, formatMessage) => {
+ if (typeKey === '0') return formatMessage({ id: 'Email' });
+ if (typeKey === '1') return formatMessage({ id: 'SMS' });
+ return `${formatMessage({ id: 'Type' })} ${typeKey}`;
+};
+
+const messageTypeLineColor = (typeKey, index) => {
+ if (typeKey === '0') return PALETTE.email;
+ if (typeKey === '1') return PALETTE.sms;
+ return PALETTE.pie[index % PALETTE.pie.length];
};
/** 当前接口:byType 为 { "0": n, "1": m },totalRecords 为数字 */
@@ -140,7 +143,7 @@ const RealtimeSection = createWithRemoteLoader({
// 时段汇总:与 hourly 序列同源(buildTodayHourlySlots),按四小时段聚合
const periodStats = useMemo(() => {
- if (!realtimeData || !hasHourlyInput(realtimeData)) return [];
+ if (!realtimeData) return [];
const slots = buildTodayHourlySlots(realtimeData);
return TIME_PERIODS.map(period => {
let email = 0;
@@ -156,84 +159,220 @@ const RealtimeSection = createWithRemoteLoader({
});
}, [realtimeData]);
- /** 今日每小时趋势:按任务类型(hourlyTrendByType) */
+ /** 今日每小时趋势:有 hourlyTrendByType 时按类型分线,否则按 slots 展示总量与邮件/短信 */
const hourlyOption = useMemo(() => {
+ if (!realtimeData) return null;
+
+ const hours = Array.from({ length: HOURS_IN_DAY }, (_, h) => `${String(h).padStart(2, '0')}:00`);
const hourlyTrendByType = Array.isArray(realtimeData?.hourlyTrendByType) ? realtimeData.hourlyTrendByType : [];
- if (hourlyTrendByType.length === 0) return null;
-
- const hours = Array.from({ length: HOURS_IN_DAY }, (_, h) => h);
- const typeMap = {};
- hourlyTrendByType.forEach(item => {
- const h = Number(item?.hour);
- const type = String(item?.type);
- if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY || !type) return;
- if (!typeMap[type]) typeMap[type] = Array.from({ length: HOURS_IN_DAY }, () => 0);
- typeMap[type][h] += Number(item?.count) || 0;
- });
- const typeKeys = Object.keys(typeMap);
- if (typeKeys.length === 0) return null;
+ if (hourlyTrendByType.length > 0) {
+ const typeMap = {};
+ hourlyTrendByType.forEach(item => {
+ const h = Number(item?.hour);
+ const typeNum = Number(item?.type);
+ if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY || !Number.isFinite(typeNum)) return;
+ const type = String(typeNum);
+ if (!typeMap[type]) typeMap[type] = Array.from({ length: HOURS_IN_DAY }, () => 0);
+ typeMap[type][h] += Number(item?.count) || 0;
+ });
+
+ const typeKeys = Object.keys(typeMap).sort((a, b) => Number(a) - Number(b));
+ if (typeKeys.length > 0) {
+ return {
+ color: typeKeys.map((key, index) => messageTypeLineColor(key, index)),
+ tooltip: tooltipStyle,
+ legend: {
+ ...legendCenterStyle,
+ data: typeKeys.map(key => messageTypeLabel(key, formatMessage))
+ },
+ grid: { ...lineChartGrid, bottom: 28 },
+ xAxis: {
+ type: 'category',
+ boundaryGap: false,
+ data: hours,
+ axisLine: axisLineStyle,
+ axisTick: { show: false },
+ axisLabel: {
+ ...axisLabelStyle,
+ fontSize: 10,
+ interval: 1,
+ rotate: 0
+ }
+ },
+ yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 },
+ series: typeKeys.map((key, index) => {
+ const color = messageTypeLineColor(key, index);
+ return {
+ name: messageTypeLabel(key, formatMessage),
+ type: 'line',
+ symbol: 'circle',
+ symbolSize: 5,
+ smooth: 0.28,
+ data: typeMap[key],
+ lineStyle: { width: 2.5, color },
+ itemStyle: { color, borderColor: '#fff', borderWidth: 2 },
+ emphasis: { focus: 'series' }
+ };
+ })
+ };
+ }
+ }
+
+ const slots = buildTodayHourlySlots(realtimeData);
+ const totalLabel = formatMessage({ id: 'TotalCount' });
+ const emailLabel = formatMessage({ id: 'Email' });
+ const smsLabel = formatMessage({ id: 'SMS' });
+ const lineFor = (data, color) => ({
+ type: 'line',
+ symbol: 'circle',
+ symbolSize: 5,
+ smooth: 0.28,
+ data,
+ lineStyle: { width: 2.5, color },
+ itemStyle: { color, borderColor: '#fff', borderWidth: 2 },
+ emphasis: { focus: 'series' }
+ });
return {
- color: PALETTE.pie,
+ color: [PALETTE.total, PALETTE.email, PALETTE.sms],
tooltip: tooltipStyle,
- legend: {
- ...legendCenterStyle,
- data: typeKeys.map(key => `Type ${key}`)
- },
+ legend: { ...legendCenterStyle, data: [totalLabel, emailLabel, smsLabel] },
grid: { ...lineChartGrid, bottom: 28 },
xAxis: {
type: 'category',
boundaryGap: false,
- data: hours.map(h => `${String(h).padStart(2, '0')}:00`),
+ data: hours,
axisLine: axisLineStyle,
axisTick: { show: false },
- axisLabel: {
- ...axisLabelStyle,
- fontSize: 10,
- interval: 1,
- rotate: 0
- }
+ axisLabel: { ...axisLabelStyle, fontSize: 10, interval: 1, rotate: 0 }
},
yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 },
- series: typeKeys.map((key, index) => ({
- name: `Type ${key}`,
- type: 'line',
- symbol: 'circle',
- symbolSize: 5,
- smooth: 0.28,
- data: typeMap[key],
- lineStyle: { width: 2.5, color: PALETTE.pie[index % PALETTE.pie.length] },
- itemStyle: { color: PALETTE.pie[index % PALETTE.pie.length], borderColor: '#fff', borderWidth: 2 },
- emphasis: { focus: 'series' }
- }))
+ series: [
+ { name: totalLabel, ...lineFor(slots.map(s => (s.hasTotalFromApi ? s.total : s.email + s.sms)), PALETTE.total) },
+ { name: emailLabel, ...lineFor(slots.map(s => s.email), PALETTE.email) },
+ { name: smsLabel, ...lineFor(slots.map(s => s.sms), PALETTE.sms) }
+ ]
};
- }, [realtimeData]);
+ }, [realtimeData, formatMessage]);
- /** 时段对比:按状态(成功/执行中/等待/取消/错误) */
+ /**
+ * 时段对比:优先按状态(hourlyTrendByStatus);消息统计接口无该字段时,
+ * 用 hourly 数据按时段汇总邮件/短信,与上方时段条一致。
+ */
const periodCompareOption = useMemo(() => {
- const hourlyTrendByStatus = Array.isArray(realtimeData?.hourlyTrendByStatus) ? realtimeData.hourlyTrendByStatus : [];
- if (hourlyTrendByStatus.length === 0) return null;
const labels = TIME_PERIODS.map(p => formatMessage({ id: p.id }));
- const statusData = STATUS_SERIES.reduce((acc, cur) => ({ ...acc, [cur.key]: [0, 0, 0, 0] }), {});
-
- hourlyTrendByStatus.forEach(item => {
- const h = Number(item?.hour);
- const status = String(item?.status || '');
- const count = Number(item?.count) || 0;
- if (!Number.isFinite(h) || !statusData[status]) return;
- const periodIndex = TIME_PERIODS.findIndex(p => h >= p.start && h < p.end);
- if (periodIndex >= 0) statusData[status][periodIndex] += count;
+ const hourlyTrendByStatus = Array.isArray(realtimeData?.hourlyTrendByStatus) ? realtimeData.hourlyTrendByStatus : [];
+
+ if (hourlyTrendByStatus.length > 0) {
+ const statusData = STATUS_SERIES.reduce((acc, cur) => ({ ...acc, [cur.key]: [0, 0, 0, 0] }), {});
+
+ hourlyTrendByStatus.forEach(item => {
+ const h = Number(item?.hour);
+ const status = String(item?.status || '');
+ const count = Number(item?.count) || 0;
+ if (!Number.isFinite(h) || !statusData[status]) return;
+ const periodIndex = TIME_PERIODS.findIndex(p => h >= p.start && h < p.end);
+ if (periodIndex >= 0) statusData[status][periodIndex] += count;
+ });
+
+ return {
+ color: STATUS_SERIES.map(item => item.color),
+ tooltip: {
+ ...tooltipStyle,
+ trigger: 'axis',
+ axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } }
+ },
+ legend: { ...legendCenterStyle, data: STATUS_SERIES.map(item => item.label) },
+ grid: { ...lineChartGrid, bottom: 20 },
+ xAxis: {
+ type: 'category',
+ data: labels,
+ axisLine: axisLineStyle,
+ axisTick: { show: false },
+ axisLabel: { color: '#64748b', fontSize: 12 }
+ },
+ yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 },
+ series: STATUS_SERIES.map((item, index) => ({
+ name: item.label,
+ type: 'bar',
+ data: statusData[item.key],
+ barMaxWidth: 24,
+ itemStyle: { color: item.color, borderRadius: [4, 4, 0, 0] },
+ emphasis: { focus: 'series' },
+ z: 10 - index
+ }))
+ };
+ }
+
+ if (!realtimeData) {
+ const emailLabel = formatMessage({ id: 'Email' });
+ const smsLabel = formatMessage({ id: 'SMS' });
+ return {
+ color: [PALETTE.email, PALETTE.sms],
+ tooltip: {
+ ...tooltipStyle,
+ trigger: 'axis',
+ axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } }
+ },
+ legend: { ...legendCenterStyle, data: [emailLabel, smsLabel] },
+ grid: { ...lineChartGrid, bottom: 20 },
+ xAxis: {
+ type: 'category',
+ data: labels,
+ axisLine: axisLineStyle,
+ axisTick: { show: false },
+ axisLabel: { color: '#64748b', fontSize: 12 }
+ },
+ yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 },
+ series: [
+ {
+ name: emailLabel,
+ type: 'bar',
+ data: [0, 0, 0, 0],
+ barMaxWidth: 28,
+ barGap: '12%',
+ itemStyle: { color: PALETTE.email, borderRadius: [4, 4, 0, 0] },
+ emphasis: { focus: 'series' },
+ z: 2
+ },
+ {
+ name: smsLabel,
+ type: 'bar',
+ data: [0, 0, 0, 0],
+ barMaxWidth: 28,
+ barGap: '12%',
+ itemStyle: { color: PALETTE.sms, borderRadius: [4, 4, 0, 0] },
+ emphasis: { focus: 'series' },
+ z: 1
+ }
+ ]
+ };
+ }
+
+ const slots = buildTodayHourlySlots(realtimeData);
+ const emailData = TIME_PERIODS.map(period => {
+ let sum = 0;
+ for (let h = period.start; h < period.end; h++) sum += slots[h].email;
+ return sum;
});
+ const smsData = TIME_PERIODS.map(period => {
+ let sum = 0;
+ for (let h = period.start; h < period.end; h++) sum += slots[h].sms;
+ return sum;
+ });
+
+ const emailLabel = formatMessage({ id: 'Email' });
+ const smsLabel = formatMessage({ id: 'SMS' });
return {
- color: STATUS_SERIES.map(item => item.color),
+ color: [PALETTE.email, PALETTE.sms],
tooltip: {
...tooltipStyle,
trigger: 'axis',
axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } }
},
- legend: { ...legendCenterStyle, data: STATUS_SERIES.map(item => item.label) },
+ legend: { ...legendCenterStyle, data: [emailLabel, smsLabel] },
grid: { ...lineChartGrid, bottom: 20 },
xAxis: {
type: 'category',
@@ -243,15 +382,28 @@ const RealtimeSection = createWithRemoteLoader({
axisLabel: { color: '#64748b', fontSize: 12 }
},
yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 },
- series: STATUS_SERIES.map((item, index) => ({
- name: item.label,
- type: 'bar',
- data: statusData[item.key],
- barMaxWidth: 24,
- itemStyle: { color: item.color, borderRadius: [4, 4, 0, 0] },
- emphasis: { focus: 'series' },
- z: 10 - index
- }))
+ series: [
+ {
+ name: emailLabel,
+ type: 'bar',
+ data: emailData,
+ barMaxWidth: 28,
+ barGap: '12%',
+ itemStyle: { color: PALETTE.email, borderRadius: [4, 4, 0, 0] },
+ emphasis: { focus: 'series' },
+ z: 2
+ },
+ {
+ name: smsLabel,
+ type: 'bar',
+ data: smsData,
+ barMaxWidth: 28,
+ barGap: '12%',
+ itemStyle: { color: PALETTE.sms, borderRadius: [4, 4, 0, 0] },
+ emphasis: { focus: 'series' },
+ z: 1
+ }
+ ]
};
}, [realtimeData, formatMessage]);
@@ -400,20 +552,12 @@ const RealtimeSection = createWithRemoteLoader({
- {periodCompareOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
+
- {hourlyOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
+
diff --git a/src/components/Task/Dashboard/HistorySection.js b/src/components/Task/Dashboard/HistorySection.js
index 0556a08..2819e50 100644
--- a/src/components/Task/Dashboard/HistorySection.js
+++ b/src/components/Task/Dashboard/HistorySection.js
@@ -16,6 +16,59 @@ import SectionHeader from './SectionHeader';
import { getClientIanaTimezone } from '../utils';
import style from './dashboard.module.scss';
+const RANGE_DAY_COUNT = { '7d': 7, '1m': 30, '1y': 365 };
+
+/** 每小时趋势「按状态」折线:避免 running 使用琥珀色易与其它图表橙色混淆 */
+const HOURLY_STATUS_LINE_COLOR_MAP = {
+ ...STATUS_COLOR_MAP,
+ running: '#7c3aed'
+};
+
+/** 本地日历上从 range 起点到今天的日期轴(YYYY-MM-DD) */
+const buildLocalDateAxisForRange = rangeKey => {
+ const days = RANGE_DAY_COUNT[rangeKey] || 7;
+ const dates = [];
+ for (let i = days - 1; i >= 0; i -= 1) {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ d.setDate(d.getDate() - i);
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ dates.push(`${y}-${m}-${day}`);
+ }
+ return dates;
+};
+
+const buildEmptyHorizontalBarOption = (placeholder, splitLineStyle, axisLineStyle, axisLabelStyle) => ({
+ tooltip: { show: false },
+ grid: { left: 6, right: 28, top: 10, bottom: 10, containLabel: true },
+ xAxis: {
+ type: 'value',
+ max: 1,
+ min: 0,
+ splitLine: splitLineStyle,
+ axisLine: { show: false },
+ axisLabel: axisLabelStyle,
+ axisTick: { show: false }
+ },
+ yAxis: {
+ type: 'category',
+ data: [placeholder],
+ axisLine: axisLineStyle,
+ axisTick: { show: false },
+ axisLabel: { ...axisLabelStyle, fontSize: 11 }
+ },
+ series: [
+ {
+ type: 'bar',
+ data: [{ value: 0, itemStyle: { color: '#e2e8f0', borderRadius: [0, 6, 6, 0] } }],
+ barMaxWidth: 26,
+ label: { show: false }
+ }
+ ]
+});
+
const HistorySection = createWithRemoteLoader({
modules: ['components-thirdparty:Echart', 'components-core:Enum']
})(
@@ -23,6 +76,8 @@ const HistorySection = createWithRemoteLoader({
const [Echart, Enum] = remoteModules;
const { formatMessage } = useIntl();
const [range, setRange] = useState('7d');
+ /** 历史每小时趋势:按任务类型 | 按任务状态 */
+ const [hourlyHistoryDim, setHourlyHistoryDim] = useState('type');
const reloadRef = useRef(() => {});
return (
@@ -60,12 +115,18 @@ const HistorySection = createWithRemoteLoader({
const trendOption = (() => {
const recentTrend = data?.recentTrend || [];
const recentTrendByType = data?.recentTrendByType || [];
- if (recentTrend.length === 0) return null;
+ const axisDates = recentTrend.length > 0 ? null : buildLocalDateAxisForRange(range);
const dateMap = {};
- recentTrend.forEach(item => {
- dateMap[item.date] = { date: item.date, total: item.count };
- });
+ if (recentTrend.length > 0) {
+ recentTrend.forEach(item => {
+ dateMap[item.date] = { date: item.date, total: item.count };
+ });
+ } else {
+ axisDates.forEach(d => {
+ dateMap[d] = { date: d, total: 0 };
+ });
+ }
recentTrendByType.forEach(item => {
if (!dateMap[item.date]) {
dateMap[item.date] = { date: item.date, total: 0 };
@@ -78,7 +139,6 @@ const HistorySection = createWithRemoteLoader({
const totals = sorted.map(item => item.total);
const manyPoints = dates.length > 14;
- // 收集所有出现过的 type
const typeSet = new Set();
recentTrendByType.forEach(item => typeSet.add(item.type));
const typeList = Array.from(typeSet);
@@ -182,7 +242,7 @@ const HistorySection = createWithRemoteLoader({
}
]
}
- : null;
+ : buildEmptyHorizontalBarOption(formatMessage({ id: 'NoData' }), splitLineStyle, axisLineStyle, axisLabelStyle);
const typeEntries = Object.entries(byType)
.map(([type, count]) => ({ type: String(type), count: Number(count) || 0 }))
@@ -193,7 +253,6 @@ const HistorySection = createWithRemoteLoader({
const durationTrend = data?.durationTrend || [];
const buildWaitExecDurationOption = rows => {
- if (!rows || rows.length === 0) return null;
const dates = rows.map(item => item.date);
const avgWait = rows.map(item => sanitizeStatisticsDurationMs(item.avgWaitingTime) ?? 0);
const avgExec = rows.map(item => sanitizeStatisticsDurationMs(item.avgExecutionTime) ?? 0);
@@ -253,22 +312,35 @@ const HistorySection = createWithRemoteLoader({
};
};
- const manualDurationRows = durationTrend.map(day => {
- const b = day?.byRunnerType?.manual;
- return {
- date: day.date,
- avgWaitingTime: b?.avgWaitingTime ?? 0,
- avgExecutionTime: b?.avgExecutionTime ?? 0
- };
- });
- const systemDurationRows = durationTrend.map(day => {
- const b = day?.byRunnerType?.system;
- return {
- date: day.date,
- avgWaitingTime: b?.avgWaitingTime ?? 0,
- avgExecutionTime: b?.avgExecutionTime ?? 0
- };
- });
+ const durationAxisDates = buildLocalDateAxisForRange(range);
+ const manualDurationRows = durationTrend.length
+ ? durationTrend.map(day => {
+ const b = day?.byRunnerType?.manual;
+ return {
+ date: day.date,
+ avgWaitingTime: b?.avgWaitingTime ?? 0,
+ avgExecutionTime: b?.avgExecutionTime ?? 0
+ };
+ })
+ : durationAxisDates.map(date => ({
+ date,
+ avgWaitingTime: 0,
+ avgExecutionTime: 0
+ }));
+ const systemDurationRows = durationTrend.length
+ ? durationTrend.map(day => {
+ const b = day?.byRunnerType?.system;
+ return {
+ date: day.date,
+ avgWaitingTime: b?.avgWaitingTime ?? 0,
+ avgExecutionTime: b?.avgExecutionTime ?? 0
+ };
+ })
+ : durationAxisDates.map(date => ({
+ date,
+ avgWaitingTime: 0,
+ avgExecutionTime: 0
+ }));
const manualDurationOption = buildWaitExecDurationOption(manualDurationRows);
const systemDurationOption = buildWaitExecDurationOption(systemDurationRows);
@@ -289,8 +361,8 @@ const HistorySection = createWithRemoteLoader({
{formatMessage({ id: 'StatusDistribution' })}
- {statusHBarOption ? (
-
+
+ {statusDistItems.length > 0 ? (
{statusDistItems.map(d => (
@@ -312,16 +384,14 @@ const HistorySection = createWithRemoteLoader({
))}
-
-
-
+ ) : null}
+
+
- ) : (
-
{formatMessage({ id: 'NoData' })}
- )}
+
@@ -383,15 +453,15 @@ const HistorySection = createWithRemoteLoader({
}
]
}
- : null;
+ : buildEmptyHorizontalBarOption(formatMessage({ id: 'NoData' }), splitLineStyle, axisLineStyle, axisLabelStyle);
return (
{formatMessage({ id: 'TaskTypeDistribution' })}
- {typeHBarOption ? (
-
+
+ {typeDistItems.length > 0 ? (
{typeDistItems.map(d => (
@@ -413,16 +483,14 @@ const HistorySection = createWithRemoteLoader({
))}
-
-
-
+ ) : null}
+
+
- ) : (
-
{formatMessage({ id: 'NoData' })}
- )}
+
);
@@ -431,63 +499,261 @@ const HistorySection = createWithRemoteLoader({
- {trendOption ? (
-
- {taskTypeList => {
- const typeLabelMap = {};
- (taskTypeList || []).forEach(item => {
- typeLabelMap[item.value] = item.label || item.description || item.value;
+
+ {taskTypeList => {
+ const typeLabelMap = {};
+ (taskTypeList || []).forEach(item => {
+ typeLabelMap[item.value] = item.label || item.description || item.value;
+ });
+ const resolvedOption = {
+ ...trendOption,
+ legend: {
+ ...trendOption.legend,
+ data: [
+ trendOption.legend.data[0],
+ ...trendOption.legend.data.slice(1).map(name => typeLabelMap[name] || name)
+ ]
+ },
+ series: trendOption.series.map((s, i) => {
+ if (i === 0) return s;
+ return { ...s, name: typeLabelMap[s.name] || s.name };
+ })
+ };
+ return ;
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {taskTypeList => {
+ const typeLabelMap = {};
+ (taskTypeList || []).forEach(item => {
+ typeLabelMap[String(item.value)] = item.label || item.description || item.value;
+ });
+
+ const hourlyCompletionTrend = data?.hourlyCompletionTrend || [];
+ const rangeDates = new Set(buildLocalDateAxisForRange(range));
+ const hourLabels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
+ const empty24 = () => Array.from({ length: 24 }, () => 0);
+
+ const statusCountField = {
+ success: 'successCount',
+ failed: 'failedCount',
+ canceled: 'canceledCount',
+ running: 'runningCount',
+ pending: 'pendingCount',
+ waiting: 'waitingCount'
+ };
+
+ const baseAxes = {
+ xAxis: {
+ type: 'category',
+ boundaryGap: false,
+ data: hourLabels,
+ axisLine: axisLineStyle,
+ axisTick: { show: false },
+ axisLabel: { ...axisLabelStyle, fontSize: 10, interval: 1 }
+ },
+ yAxis: {
+ type: 'value',
+ ...splitLineStyle,
+ axisLabel: { ...axisLabelStyle, minInterval: 1 },
+ min: 0
+ }
+ };
+
+ let option;
+ if (hourlyHistoryDim === 'status') {
+ const seriesData = {};
+ TASK_STATUS_LIST.forEach(s => {
+ seriesData[s] = empty24();
+ });
+ hourlyCompletionTrend.forEach(r => {
+ if (!r?.date || !rangeDates.has(r.date)) return;
+ const h = Number(r.hour);
+ if (!Number.isFinite(h) || h < 0 || h >= 24) return;
+ if (r.status != null && (r.count != null || r.totalCompleted != null)) {
+ const st = String(r.status);
+ if (seriesData[st]) {
+ seriesData[st][h] += Number(r.count != null ? r.count : r.totalCompleted) || 0;
+ }
+ return;
+ }
+ TASK_STATUS_LIST.forEach(st => {
+ const field = statusCountField[st];
+ if (field && r[field] != null) {
+ seriesData[st][h] += Number(r[field]) || 0;
+ }
});
- const resolvedOption = {
- ...trendOption,
- legend: {
- ...trendOption.legend,
- data: [
- trendOption.legend.data[0],
- ...trendOption.legend.data.slice(1).map(name => typeLabelMap[name] || name)
- ]
+ });
+ const activeStatuses = TASK_STATUS_LIST.filter(st =>
+ seriesData[st].reduce((a, b) => a + b, 0) > 0
+ );
+ if (activeStatuses.length === 0) {
+ const totals = empty24();
+ hourlyCompletionTrend.forEach(r => {
+ if (!r?.date || !rangeDates.has(r.date)) return;
+ const hh = Number(r.hour);
+ if (!Number.isFinite(hh) || hh < 0 || hh >= 24) return;
+ totals[hh] += Number(r.totalCompleted) || 0;
+ });
+ const totalLabel = formatMessage({ id: 'TotalCount' });
+ option = {
+ color: [PALETTE.total],
+ tooltip: tooltipStyle,
+ legend: { ...legendCenterStyle, data: [totalLabel] },
+ grid: { ...lineChartGrid, bottom: 28 },
+ ...baseAxes,
+ series: [
+ {
+ name: totalLabel,
+ type: 'line',
+ smooth: lineSmooth,
+ symbol: 'circle',
+ symbolSize: 5,
+ data: totals,
+ lineStyle: { width: 2, color: PALETTE.total },
+ itemStyle: { color: PALETTE.total, borderColor: '#fff', borderWidth: 1 },
+ emphasis: { focus: 'series' },
+ areaStyle: { color: `${PALETTE.total}18` }
+ }
+ ]
+ };
+ } else {
+ const legendData = activeStatuses.map(s =>
+ formatMessage({ id: s.charAt(0).toUpperCase() + s.slice(1) })
+ );
+ const series = activeStatuses.map((st, i) => ({
+ name: legendData[i],
+ type: 'line',
+ smooth: lineSmooth,
+ symbol: 'circle',
+ symbolSize: 4,
+ data: seriesData[st],
+ lineStyle: { width: 1.5, color: HOURLY_STATUS_LINE_COLOR_MAP[st] },
+ itemStyle: { color: HOURLY_STATUS_LINE_COLOR_MAP[st], borderColor: '#fff', borderWidth: 1 },
+ emphasis: { focus: 'series' }
+ }));
+ option = {
+ color: activeStatuses.map(s => HOURLY_STATUS_LINE_COLOR_MAP[s]),
+ tooltip: tooltipStyle,
+ legend: { ...legendCenterStyle, data: legendData },
+ grid: { ...lineChartGrid, bottom: 28 },
+ ...baseAxes,
+ series
+ };
+ }
+ } else {
+ const byType = {};
+ hourlyCompletionTrend.forEach(r => {
+ if (!r?.date || !rangeDates.has(r.date)) return;
+ const h = Number(r.hour);
+ if (!Number.isFinite(h) || h < 0 || h >= 24) return;
+ const t = r.type != null && r.type !== '' ? String(r.type) : null;
+ if (!t) return;
+ if (!byType[t]) byType[t] = empty24();
+ byType[t][h] += Number(r.totalCompleted) || 0;
+ });
+ const typeKeys = Object.keys(byType).sort();
+ if (typeKeys.length === 0) {
+ const totals = empty24();
+ hourlyCompletionTrend.forEach(r => {
+ if (!r?.date || !rangeDates.has(r.date)) return;
+ const h = Number(r.hour);
+ if (!Number.isFinite(h) || h < 0 || h >= 24) return;
+ totals[h] += Number(r.totalCompleted) || 0;
+ });
+ const totalLabel = formatMessage({ id: 'TotalCount' });
+ option = {
+ color: [PALETTE.total],
+ tooltip: tooltipStyle,
+ legend: { ...legendCenterStyle, data: [totalLabel] },
+ grid: { ...lineChartGrid, bottom: 28 },
+ ...baseAxes,
+ series: [
+ {
+ name: totalLabel,
+ type: 'line',
+ smooth: lineSmooth,
+ symbol: 'circle',
+ symbolSize: 5,
+ data: totals,
+ lineStyle: { width: 2, color: PALETTE.total },
+ itemStyle: { color: PALETTE.total, borderColor: '#fff', borderWidth: 1 },
+ emphasis: { focus: 'series' },
+ areaStyle: { color: `${PALETTE.total}18` }
+ }
+ ]
+ };
+ } else {
+ const legendData = typeKeys.map(t => typeLabelMap[t] || t);
+ const series = typeKeys.map((t, i) => ({
+ name: typeLabelMap[t] || t,
+ type: 'line',
+ smooth: lineSmooth,
+ symbol: 'circle',
+ symbolSize: 4,
+ data: byType[t],
+ lineStyle: { width: 1.5, color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length] },
+ itemStyle: {
+ color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length],
+ borderColor: '#fff',
+ borderWidth: 1
},
- series: trendOption.series.map((s, i) => {
- if (i === 0) return s;
- return { ...s, name: typeLabelMap[s.name] || s.name };
- })
+ emphasis: { focus: 'series' }
+ }));
+ option = {
+ color: typeKeys.map((_, i) => TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length]),
+ tooltip: tooltipStyle,
+ legend: { ...legendCenterStyle, data: legendData },
+ grid: { ...lineChartGrid, bottom: 28 },
+ ...baseAxes,
+ series
};
- return ;
- }}
-
- ) : (
-
{formatMessage({ id: 'NoData' })}
- )}
-
+ }
+ }
- {durationTrend.length > 0 ? (
-
-
-
- {manualDurationOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
-
-
-
+ return (
+ }
>
- {systemDurationOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
+
-
-
- ) : null}
+ );
+ }}
+
>
);
}}
diff --git a/src/components/Task/Dashboard/RealtimeSection.js b/src/components/Task/Dashboard/RealtimeSection.js
index ce3c7f6..94934e8 100644
--- a/src/components/Task/Dashboard/RealtimeSection.js
+++ b/src/components/Task/Dashboard/RealtimeSection.js
@@ -1,7 +1,7 @@
import { createWithRemoteLoader } from '@kne/remote-loader';
-import { useMemo, useCallback } from 'react';
+import { useMemo, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { Col, Row, Space, Tag } from 'antd';
+import { Col, Row, Segmented, Space, Tag } from 'antd';
import { BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { Card as BoxCard, ColorfulCard } from '@kne/react-box';
import withLocale from '../withLocale';
@@ -20,13 +20,25 @@ import {
splitLineStyle,
formatDuration,
pickStatisticsDurationMs,
- sanitizeStatisticsDurationMs
+ sanitizeStatisticsDurationMs,
+ resolveDurationMsForDashboard,
+ sortTaskTypeKeys
} from './constants';
import useRealtimeStatisticsSSE from './useRealtimeStatisticsSSE';
import style from './dashboard.module.scss';
const HOURS_IN_DAY = 24;
+/** 取第一个有效非负整数;用于统计类字段的多来源兼容 */
+const pickNonNegativeInt = (...candidates) => {
+ for (const v of candidates) {
+ if (v === undefined || v === null) continue;
+ const n = Number(v);
+ if (Number.isFinite(n) && n >= 0) return Math.trunc(n);
+ }
+ return 0;
+};
+
const buildTodayHourlySlots = raw => {
const slots = Array.from({ length: HOURS_IN_DAY }, (_, h) => ({
hour: h, total: 0, hasTotalFromApi: false, byType: {}
@@ -49,12 +61,6 @@ const buildTodayHourlySlots = raw => {
return slots;
};
-const hasHourlyInput = raw => {
- const a = raw?.hourlyTrend;
- const b = raw?.hourlyTrendByType;
- return (Array.isArray(a) && a.length > 0) || (Array.isArray(b) && b.length > 0);
-};
-
const RealtimeSection = createWithRemoteLoader({
modules: ['components-thirdparty:Echart', 'components-core:Enum']
})(
@@ -82,6 +88,8 @@ const RealtimeSection = createWithRemoteLoader({
);
const sseUrl = apis?.task?.statistics?.sse?.url;
const { realtimeData, isConnected, lastUpdatedAt } = useRealtimeStatisticsSSE(sseUrl);
+ /** 执行时间统计三张卡 + 按类型耗时图:全部 | 手动 | 自动(todayDuration / byTypeByRunnerType) */
+ const [durationByTypeRunnerMode, setDurationByTypeRunnerMode] = useState('all');
const byStatus = realtimeData?.byStatus || {};
const byRunnerType = realtimeData?.byRunnerType || {};
@@ -93,25 +101,35 @@ const RealtimeSection = createWithRemoteLoader({
const canceledCount = Number(byStatus.canceled) || 0;
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(sanitizeStatisticsDurationMs(manualDur.avgWaitingTime)),
- avgExecutionTime: formatDuration(sanitizeStatisticsDurationMs(manualDur.avgExecutionTime)),
- avgTotalTime: formatDuration(sanitizeStatisticsDurationMs(manualDur.avgTotalTime))
- };
- }, [realtimeData]);
+ /**
+ * 手动「等待操作」:**主展示条数**(waitingByRunnerType.manual 等);次展示队列内
+ * 最长等待(`waitingQueueMaxWaitMsByRunnerType.manual`,当前时间 − createdAt)。
+ */
+ const manualPendingCount = pickNonNegativeInt(
+ realtimeData?.waitingByRunnerType?.manual,
+ manualRt?.waiting,
+ manualRt?.waitingCount
+ );
+ /**
+ * 手动「当日完成」:**主展示条数**(completedToday.manual);次展示当日完成任务的
+ * 创建→完成耗时之和(`completedTodayTotalDurationMsByRunnerType.manual`)。
+ */
+ const manualExecutedCount = pickNonNegativeInt(realtimeData?.completedToday?.manual);
+
+ const manualPendingMaxWaitDisplay = useMemo(() => {
+ if (manualPendingCount <= 0) return '—';
+ const ms = resolveDurationMsForDashboard(realtimeData?.waitingQueueMaxWaitMsByRunnerType?.manual);
+ return ms != null ? formatDuration(ms) : '—';
+ }, [realtimeData, manualPendingCount]);
+
+ const manualCompletedTotalDurationDisplay = useMemo(() => {
+ if (manualExecutedCount <= 0) return '—';
+ const ms = resolveDurationMsForDashboard(realtimeData?.completedTodayTotalDurationMsByRunnerType?.manual);
+ return ms != null ? formatDuration(ms) : '—';
+ }, [realtimeData, manualExecutedCount]);
const lastUpdatedShort = useMemo(() => {
if (!lastUpdatedAt) return '';
@@ -125,7 +143,7 @@ const RealtimeSection = createWithRemoteLoader({
}, [lastUpdatedAt]);
const periodStats = useMemo(() => {
- if (!realtimeData || !hasHourlyInput(realtimeData)) return [];
+ if (!realtimeData) return [];
const slots = buildTodayHourlySlots(realtimeData);
return TIME_PERIODS.map(period => {
let total = 0;
@@ -141,17 +159,53 @@ const RealtimeSection = createWithRemoteLoader({
});
}, [realtimeData]);
+ /** 与「今日每小时趋势」series 顺序一致;下方时段条按此列表取色,避免同色不同 type */
+ const todayHourlyTaskTypeOrder = useMemo(() => {
+ if (!realtimeData) return [];
+ const slots = buildTodayHourlySlots(realtimeData);
+ const typeSet = new Set();
+ slots.forEach(s => Object.keys(s.byType).forEach(t => typeSet.add(t)));
+ return sortTaskTypeKeys(typeSet);
+ }, [realtimeData]);
+
const hourlyOption = useMemo(() => {
- if (!realtimeData || !hasHourlyInput(realtimeData)) return null;
+ if (!realtimeData) return null;
const slots = buildTodayHourlySlots(realtimeData);
const hours = slots.map(s => `${String(s.hour).padStart(2, '0')}:00`);
- // 收集所有出现过的 type
- const typeSet = new Set();
- slots.forEach(s => Object.keys(s.byType).forEach(t => typeSet.add(t)));
- const typeList = Array.from(typeSet);
- if (typeList.length === 0) return null;
+ const typeList = todayHourlyTaskTypeOrder;
+
+ if (typeList.length === 0) {
+ const totalLabel = formatMessage({ id: 'TotalCount' });
+ return {
+ color: [PALETTE.total],
+ tooltip: tooltipStyle,
+ legend: { ...legendCenterStyle, data: [totalLabel] },
+ grid: { ...lineChartGrid, bottom: 28 },
+ xAxis: {
+ type: 'category', boundaryGap: false, data: hours,
+ axisLine: axisLineStyle, axisTick: { show: false },
+ axisLabel: { ...axisLabelStyle, fontSize: 10, interval: 1 }
+ },
+ yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 },
+ series: [
+ {
+ name: totalLabel,
+ type: 'line',
+ smooth: lineSmooth,
+ symbol: 'circle',
+ symbolSize: 4,
+ showSymbol: true,
+ data: slots.map(s => s.total),
+ lineStyle: { width: 1.5, color: PALETTE.total },
+ itemStyle: { color: PALETTE.total, borderColor: '#fff', borderWidth: 1 },
+ emphasis: { focus: 'series' },
+ areaStyle: { color: `${PALETTE.total}18` }
+ }
+ ]
+ };
+ }
const typeSeries = typeList.map((type, i) => ({
name: type,
@@ -183,11 +237,10 @@ const RealtimeSection = createWithRemoteLoader({
yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 },
series: typeSeries
};
- }, [realtimeData]);
+ }, [realtimeData, formatMessage, todayHourlyTaskTypeOrder]);
const periodCompareOption = useMemo(() => {
const hourlyTrendByStatus = Array.isArray(realtimeData?.hourlyTrendByStatus) ? realtimeData.hourlyTrendByStatus : [];
- if (hourlyTrendByStatus.length === 0) return null;
const labels = TIME_PERIODS.map(p => formatMessage({ id: p.id }));
const statusKeys = ['success', 'running', 'waiting', 'canceled', 'failed'];
const statusLabelMap = {
@@ -230,27 +283,140 @@ const RealtimeSection = createWithRemoteLoader({
const durationDisplay = useMemo(() => {
const dur = realtimeData?.todayDuration;
if (!dur) return null;
+
+ if (durationByTypeRunnerMode === 'all') {
+ return {
+ avgWaitingTime: formatDuration(pickStatisticsDurationMs(dur, 'avgWaitingTime')),
+ avgExecutionTime: formatDuration(pickStatisticsDurationMs(dur, 'avgExecutionTime')),
+ avgTotalTime: formatDuration(pickStatisticsDurationMs(dur, 'avgTotalTime'))
+ };
+ }
+
+ const source =
+ dur.byRunnerType && typeof dur.byRunnerType === 'object'
+ ? dur.byRunnerType[durationByTypeRunnerMode]
+ : undefined;
+ const pickSlice = field => formatDuration(pickStatisticsDurationMs(source, field, []));
+
return {
- avgWaitingTime: formatDuration(pickStatisticsDurationMs(dur, 'avgWaitingTime')),
- avgExecutionTime: formatDuration(pickStatisticsDurationMs(dur, 'avgExecutionTime')),
- avgTotalTime: formatDuration(pickStatisticsDurationMs(dur, 'avgTotalTime'))
+ avgWaitingTime: pickSlice('avgWaitingTime'),
+ avgExecutionTime: pickSlice('avgExecutionTime'),
+ avgTotalTime: pickSlice('avgTotalTime')
};
- }, [realtimeData]);
+ }, [realtimeData, durationByTypeRunnerMode]);
const durationByTypeOption = useMemo(() => {
- const todayDur = realtimeData?.todayDuration;
- if (!todayDur || typeof todayDur !== 'object') return null;
+ if (!realtimeData) return null;
- const durationByType =
- todayDur.byType && typeof todayDur.byType === 'object' ? todayDur.byType : {};
- const countByType =
+ const todayDur = realtimeData?.todayDuration;
+ const byRunnerSplit = todayDur?.byTypeByRunnerType;
+ let durationByType = {};
+ let countByType =
realtimeData.byType && typeof realtimeData.byType === 'object' ? realtimeData.byType : {};
+ if (durationByTypeRunnerMode === 'manual') {
+ const m = byRunnerSplit?.manual;
+ if (m && typeof m === 'object') {
+ durationByType = m;
+ countByType = Object.fromEntries(
+ Object.keys(durationByType).map(k => {
+ const d = durationByType[k];
+ const c = d && typeof d === 'object' ? Number(d.count) || 0 : 0;
+ return [k, c];
+ })
+ );
+ } else {
+ durationByType = {};
+ countByType = {};
+ }
+ } else if (durationByTypeRunnerMode === 'system') {
+ const s = byRunnerSplit?.system;
+ if (s && typeof s === 'object') {
+ durationByType = s;
+ countByType = Object.fromEntries(
+ Object.keys(durationByType).map(k => {
+ const d = durationByType[k];
+ const c = d && typeof d === 'object' ? Number(d.count) || 0 : 0;
+ return [k, c];
+ })
+ );
+ } else {
+ durationByType = {};
+ countByType = {};
+ }
+ } else {
+ durationByType =
+ todayDur && typeof todayDur === 'object' && todayDur.byType && typeof todayDur.byType === 'object'
+ ? todayDur.byType
+ : {};
+ 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 noDataLabel = formatMessage({ id: 'NoData' });
+ if (typeSet.size === 0) {
+ return {
+ color: [PALETTE.running, PALETTE.waiting, PALETTE.total],
+ tooltip: {
+ ...tooltipStyle,
+ axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } },
+ formatter: params => {
+ const title = params?.[0]?.axisValue || '';
+ const lines = (params || []).map(p => `${p.marker} ${p.seriesName}: ${formatDuration(p.value)}`).join('
');
+ return `
${title}
${lines}`;
+ }
+ },
+ legend: {
+ ...legendCenterStyle,
+ data: [
+ formatMessage({ id: 'AvgExecutionTime' }),
+ formatMessage({ id: 'AvgWaitingTime' }),
+ formatMessage({ id: 'AvgTotalTime' })
+ ]
+ },
+ grid: { ...lineChartGrid, bottom: 20 },
+ xAxis: {
+ type: 'category',
+ data: [noDataLabel],
+ axisLine: axisLineStyle,
+ axisTick: { show: false },
+ axisLabel: axisLabelStyle
+ },
+ yAxis: {
+ type: 'value',
+ ...splitLineStyle,
+ axisLabel: { ...axisLabelStyle, formatter: value => formatDuration(value) },
+ min: 0
+ },
+ series: [
+ {
+ name: formatMessage({ id: 'AvgExecutionTime' }),
+ type: 'bar',
+ data: [0],
+ barMaxWidth: 28,
+ itemStyle: { color: PALETTE.running, borderRadius: [4, 4, 0, 0] }
+ },
+ {
+ name: formatMessage({ id: 'AvgWaitingTime' }),
+ type: 'bar',
+ data: [0],
+ barMaxWidth: 28,
+ itemStyle: { color: PALETTE.waiting, borderRadius: [4, 4, 0, 0] }
+ },
+ {
+ name: formatMessage({ id: 'AvgTotalTime' }),
+ type: 'bar',
+ data: [0],
+ barMaxWidth: 28,
+ itemStyle: { color: PALETTE.total, borderRadius: [4, 4, 0, 0] }
+ }
+ ]
+ };
+ }
const entries = Array.from(typeSet)
.map(type => {
@@ -322,7 +488,7 @@ const RealtimeSection = createWithRemoteLoader({
}
]
};
- }, [realtimeData, formatMessage]);
+ }, [realtimeData, formatMessage, durationByTypeRunnerMode]);
return (
<>
@@ -406,45 +572,42 @@ const RealtimeSection = createWithRemoteLoader({
}
- title={
{manualPendingCount}}
- description={
{formatMessage({ id: 'ManualPendingTasks' })}}
+ style={{ textAlign: 'left' }}
+ title={
{manualPendingCount}}
+ description={
+
+
{formatMessage({ id: 'ManualPendingTasks' })}
+
+ {formatMessage({ id: 'ManualPendingMaxWaitLabel' })}
+
+ {manualPendingMaxWaitDisplay}
+
+
+
+ }
/>
}
+ style={{ textAlign: 'left' }}
title={
{manualExecutedCount}}
- description={
{formatMessage({ id: 'ManualExecutedTasks' })}}
+ description={
+
+
{formatMessage({ id: 'ManualExecutedTasks' })}
+
+ {formatMessage({ id: 'ManualCompletedTotalDurationLabel' })}
+
+ {manualCompletedTotalDurationDisplay}
+
+
+
+ }
/>
- {manualDurationDisplay && (
-
- }
- title={{manualDurationDisplay.avgExecutionTime}}
- description={{formatMessage({ id: 'ManualAvgExecutionTime' })}}
- />
- }
- title={{manualDurationDisplay.avgWaitingTime}}
- description={{formatMessage({ id: 'ManualAvgWaitingTime' })}}
- />
- }
- title={{manualDurationDisplay.avgTotalTime}}
- description={{formatMessage({ id: 'ManualAvgTotalTime' })}}
- />
-
- )}
{formatMessage({ id: 'RealtimeTaskOverview' })}
@@ -494,7 +657,20 @@ const RealtimeSection = createWithRemoteLoader({
- {formatMessage({ id: 'ExecutionTimeStatistics' })}
+
+ {formatMessage({ id: 'ExecutionTimeStatistics' })}
+
+
{durationDisplay ? (
<>
@@ -527,12 +703,11 @@ const RealtimeSection = createWithRemoteLoader({
)}
-
- {resolvedDurationByTypeOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
+
+
@@ -567,12 +742,18 @@ const RealtimeSection = createWithRemoteLoader({
- {Object.entries(period.byType)
- .map(([type, count]) => ({ type, count: Number(count) || 0 }))
- .filter(({ count }) => count > 0)
- .map(({ type, count }, i) => {
- const color = TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length];
- return (
+ {sortTaskTypeKeys(
+ Object.entries(period.byType)
+ .filter(([, c]) => (Number(c) || 0) > 0)
+ .map(([type]) => type)
+ ).map(type => {
+ const count = Number(period.byType[type]) || 0;
+ const ci = todayHourlyTaskTypeOrder.indexOf(type);
+ const color =
+ TASK_TYPE_COLOR_MAP[
+ (ci >= 0 ? ci : 0) % TASK_TYPE_COLOR_MAP.length
+ ];
+ return (
{count}
);
- })}
+ })}
@@ -595,20 +776,12 @@ const RealtimeSection = createWithRemoteLoader({
- {periodCompareOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
+
- {resolvedHourlyOption ? (
-
- ) : (
- {formatMessage({ id: 'NoData' })}
- )}
+
diff --git a/src/components/Task/Dashboard/constants.js b/src/components/Task/Dashboard/constants.js
index 43f2358..9111e63 100644
--- a/src/components/Task/Dashboard/constants.js
+++ b/src/components/Task/Dashboard/constants.js
@@ -1,3 +1,5 @@
+import { defaultColors } from '@kne/react-box';
+
export const RANGE_OPTIONS = ['7d', '1m', '1y'];
export const buildUrlWithParams = (url, params = {}) => {
@@ -41,7 +43,21 @@ export const STATUS_COLOR_MAP = {
canceled: PALETTE.canceled
};
-export const TASK_TYPE_COLOR_MAP = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316', '#14b8a6'];
+/**
+ * 任务类型折线/柱状:色值全部来自 @kne/react-box 的 defaultColors(与 ColorfulCard 同源)。
+ * 顺序按 key 打散,减轻相邻系列里橙/黄等相近色贴在一起;不含 Black,避免细线对比度不足。
+ */
+const TASK_TYPE_COLOR_KEYS = ['Purple', 'Green', 'Orange', 'Blue', 'Pink', 'Yellow', 'Red', 'Gray'];
+
+export const TASK_TYPE_COLOR_MAP = TASK_TYPE_COLOR_KEYS.map(key => defaultColors[key]);
+
+/** 任务类型 key 稳定排序:折线图 series 与下方 Tag 等共用,保证同一 type 色标一致 */
+export const sortTaskTypeKeys = types => {
+ const arr = types == null ? [] : [...types];
+ return arr.sort((a, b) =>
+ String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })
+ );
+};
export const tooltipStyle = {
trigger: 'axis',
@@ -134,6 +150,9 @@ export const splitLineStyle = { lineStyle: { color: '#f1f5f9', type: 'dashed', w
/** 接口约定为毫秒;超出此上限视为异常数据(如错误聚合),不参与展示与加权回算 */
export const MAX_STATISTICS_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
+/** 看板「多任务耗时合计」等展示用:允许大于单任务校验上限,仍防极端异常值 */
+export const DASHBOARD_AGGREGATE_DURATION_CAP_MS = 5 * 365 * 24 * 60 * 60 * 1000;
+
export const sanitizeStatisticsDurationMs = value => {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return null;
@@ -141,6 +160,15 @@ export const sanitizeStatisticsDurationMs = value => {
return n;
};
+/** 单任务仍走 sanitize;超长队列等待或合计耗时在 cap 内用于展示 */
+export const resolveDurationMsForDashboard = raw => {
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n < 0) return null;
+ const sane = sanitizeStatisticsDurationMs(n);
+ if (sane != null) return sane;
+ return Math.min(n, DASHBOARD_AGGREGATE_DURATION_CAP_MS);
+};
+
/**
* 优先使用容器顶层字段(经 sanitize);无效时按 count 对 byType、byRunnerType 做加权回算(毫秒)。
*/
diff --git a/src/components/Task/Dashboard/dashboard.module.scss b/src/components/Task/Dashboard/dashboard.module.scss
index 3ba3d10..57b549a 100644
--- a/src/components/Task/Dashboard/dashboard.module.scss
+++ b/src/components/Task/Dashboard/dashboard.module.scss
@@ -117,6 +117,11 @@
display: block;
}
+/** 手动面板首张 KPI:标题数字与下方块左对齐 */
+.manualKpiCard {
+ text-align: left;
+}
+
.kpiDescDense {
font-size: 11px;
line-height: 1.3;
@@ -124,6 +129,72 @@
opacity: 0.92;
}
+.kpiDescMeta {
+ display: block;
+ margin-top: 3px;
+ font-size: 10px;
+ font-weight: 500;
+ opacity: 0.88;
+}
+
+/** 手动 KPI:主数字下方说明 + 带标签的时间块(左对齐) */
+.kpiManualCardDesc {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ margin-top: 4px;
+ width: 100%;
+ text-align: left;
+}
+
+.kpiManualCardPurpose {
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.35;
+ color: #475569;
+ letter-spacing: 0.01em;
+ text-align: left;
+}
+
+.manualKpiTimeBlock {
+ text-align: left;
+ padding: 8px 10px 9px;
+ border-radius: 9px;
+ border: 1px solid rgba(148, 163, 184, 0.38);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.94) 100%);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+}
+
+.manualKpiTimeBlockPending {
+ border-color: rgba(239, 68, 68, 0.28);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(254, 242, 242, 0.55) 100%);
+}
+
+.manualKpiTimeBlockDone {
+ border-color: rgba(16, 185, 129, 0.3);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(236, 253, 245, 0.55) 100%);
+}
+
+.manualKpiTimeLabel {
+ display: block;
+ font-size: 11px;
+ font-weight: 700;
+ color: #334155;
+ letter-spacing: 0.02em;
+ line-height: 1.25;
+}
+
+.manualKpiTimeValue {
+ display: block;
+ margin-top: 6px;
+ font-size: clamp(0.9rem, 2.6vw, 1.05rem);
+ font-weight: 750;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.03em;
+ line-height: 1.15;
+}
+
.realtimeGroupTitle {
margin-top: 14px;
margin-bottom: 10px;
@@ -133,6 +204,20 @@
letter-spacing: 0.02em;
}
+.executionTimeStatHead {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px 12px;
+ margin-top: 14px;
+ margin-bottom: 10px;
+}
+
+.executionTimeStatHead .realtimeGroupTitle {
+ margin: 0;
+}
+
.realtimeSectionDivider {
height: 1px;
margin: 14px 0 6px;
@@ -185,15 +270,6 @@
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.1);
}
-.manualPendingValue {
- font-size: clamp(1.8rem, 3.8vw, 2.3rem);
- font-weight: 800;
-}
-
-.manualDurationRow {
- margin-top: 8px;
-}
-
.periodStrip {
display: flex;
flex-direction: row;
@@ -290,6 +366,14 @@
margin-top: 10px;
}
+/* 每小时趋势 / 按类型耗时:Segmented 字重(卡片标题在 @kne/react-box 内为 h4,切换区走 extra) */
+.historyHourlyTrendSegmented,
+.durationByTypeRunnerSegmented {
+ :global(.ant-segmented-item-label) {
+ font-weight: 400;
+ }
+}
+
/* 总任务数:在占比区域外,与下方图表卡片区分 */
.historyOverviewMeta {
display: flex;
diff --git a/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js b/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js
index 11b99ce..29a0be8 100644
--- a/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js
+++ b/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js
@@ -3,6 +3,8 @@ import { getToken } from '@kne/token-storage';
import { buildUrlWithParams } from './constants';
import { getClientIanaTimezone } from '../utils';
+/** 任务实时统计 SSE:负载字段约定见 `src/components/Task/doc/api.md`「任务实时统计 SSE」。 */
+
const isLikelyTaskStatisticsPayload = obj =>
obj &&
typeof obj === 'object' &&
@@ -14,7 +16,9 @@ const isLikelyTaskStatisticsPayload = obj =>
'byType' in obj ||
'todayDuration' in obj ||
'pendingByRunnerType' in obj ||
- 'runnerTypeStats' in obj);
+ 'runnerTypeStats' in obj ||
+ 'waitingByRunnerType' in obj ||
+ 'completedToday' in obj);
const unwrapStatisticsPayload = parsed => {
if (parsed == null) return null;
diff --git a/src/components/Task/README.md b/src/components/Task/README.md
index 66f6a71..602fa37 100644
--- a/src/components/Task/README.md
+++ b/src/components/Task/README.md
@@ -634,3 +634,70 @@ render();
- success: 成功
- failed: 失败
- canceled: 取消
+
+---
+
+## 任务统计 HTTP
+
+**路径**:`GET /api/v1/task/statistics`(与 `apis.task.statistics.getOverview` 对应)
+
+**Query**
+
+| 参数 | 说明 |
+| --- | --- |
+| `range` | 历史区间:`7d` \| `1m` \| `1y` |
+| `timezone` | 浏览器 IANA 时区(如 `Asia/Shanghai`),与后端按日历日聚合的划界一致 |
+
+**响应**(节选,以实际后端为准)
+
+| 字段 | 说明 |
+| --- | --- |
+| `recentTrend` / `recentTrendByType` / `recentTrendByStatus` | 历史趋势 |
+| `durationTrend` | 耗时趋势 |
+| `hourlyCompletionTrend` | 历史「每小时趋势」:`date`、`hour`、`type`、`totalCompleted` 及按状态拆分字段等 |
+| `byStatus` / `byType` / `byRunnerType` | 聚合计数 |
+
+---
+
+## 任务实时统计 SSE
+
+**路径**:`GET /api/v1/task/statistics/sse`(与 `apis.task.statistics.sse` 对应)
+
+**客户端**:使用 `EventSource` 拉流;前端会在 URL 上追加 query(见 `useRealtimeStatisticsSSE`):
+
+| 参数 | 说明 |
+| --- | --- |
+| `interval` | 推送间隔(秒),如 `5` |
+| `token` | 鉴权,如 `X-User-Token` |
+| `timezone` | IANA 时区,与「今日」、当日完成数划界一致 |
+
+**每条事件的 `data`**
+
+为 JSON 字符串,解析后为对象(或 `{ "data": { ... } }`,前端会解包)。对象内为**增量或全量**字段均可;前端对 SSE 负载做浅合并。
+
+### 看板「手动执行任务」指标(须与实现对齐)
+
+前端见 `Dashboard/RealtimeSection.js`:「等待操作」用 `pickNonNegativeInt` 读取 `waiting` 相关字段;「当日完成」**仅**读取 **`completedToday.manual`**。未下发时显示 `0`。
+
+| 指标 | 含义 | 字段 |
+| --- | --- | --- |
+| 手动 · 当前为 **waiting**(等待操作)的任务数 | 快照:当前处于 `waiting` 状态且 `runnerType === manual` 的任务数量 | **`waitingByRunnerType.manual`**,或 **`runnerTypeStats.manual.waiting`** |
+| 系统 · 同上 | `runnerType === system` | **`waitingByRunnerType.system`**,或 **`runnerTypeStats.system.waiting`** |
+| 手动 · **当日完成**任务数 | 完成时间落在「当日」(由后端按 `timezone` 划界)的手动任务数 | **`completedToday.manual`**(顶层字段,与 `waitingByRunnerType` 并列) |
+| 系统 · 当日完成 | 同上 | **`completedToday.system`** |
+
+(不再使用 `todayCompletedByRunnerType`、`runnerTypeStats.*.completedToday`、`todayCompleted`、`executedToday` 等别名。)
+
+### 其它常用 SSE 字段(节选)
+
+| 字段 | 说明 |
+| --- | --- |
+| `date` | 服务端「今日」日期(YYYY-MM-DD,可与 timezone 对齐校验) |
+| `totalTasks` | 今日任务量等总览 |
+| `byStatus` | 各状态计数(含 `waiting`,与 `pending` 不同) |
+| `byType` / `byRunnerType` | 按类型 / 按执行方式聚合 |
+| `hourlyTrend` / `hourlyTrendByStatus` / `hourlyTrendByType` | 今日按小时序列 |
+| `completedToday` | **当日完成**按 `manual` / `system` 分组(顶层对象,与 `waitingByRunnerType` 并列) |
+| `pendingByRunnerType` | **pending(等待执行)** 按 `manual` / `system` 分组;与看板「手动等待操作数」(`waiting`)无关 |
+| `runnerTypeStats` | 可按 runner 扩展;看板「手动等待操作数」仅读 **`waiting` / `waitingCount`**;**`executed`** 勿用作「当日完成」(当日完成仅 **`completedToday`**) |
+| `todayDuration` | 今日耗时统计(均值等) |
diff --git a/src/components/Task/doc/api.md b/src/components/Task/doc/api.md
index fd25a7b..e4f30c4 100644
--- a/src/components/Task/doc/api.md
+++ b/src/components/Task/doc/api.md
@@ -77,3 +77,70 @@
- success: 成功
- failed: 失败
- canceled: 取消
+
+---
+
+## 任务统计 HTTP
+
+**路径**:`GET /api/v1/task/statistics`(与 `apis.task.statistics.getOverview` 对应)
+
+**Query**
+
+| 参数 | 说明 |
+| --- | --- |
+| `range` | 历史区间:`7d` \| `1m` \| `1y` |
+| `timezone` | 浏览器 IANA 时区(如 `Asia/Shanghai`),与后端按日历日聚合的划界一致 |
+
+**响应**(节选,以实际后端为准)
+
+| 字段 | 说明 |
+| --- | --- |
+| `recentTrend` / `recentTrendByType` / `recentTrendByStatus` | 历史趋势 |
+| `durationTrend` | 耗时趋势 |
+| `hourlyCompletionTrend` | 历史「每小时趋势」:`date`、`hour`、`type`、`totalCompleted` 及按状态拆分字段等 |
+| `byStatus` / `byType` / `byRunnerType` | 聚合计数 |
+
+---
+
+## 任务实时统计 SSE
+
+**路径**:`GET /api/v1/task/statistics/sse`(与 `apis.task.statistics.sse` 对应)
+
+**客户端**:使用 `EventSource` 拉流;前端会在 URL 上追加 query(见 `useRealtimeStatisticsSSE`):
+
+| 参数 | 说明 |
+| --- | --- |
+| `interval` | 推送间隔(秒),如 `5` |
+| `token` | 鉴权,如 `X-User-Token` |
+| `timezone` | IANA 时区,与「今日」、当日完成数划界一致 |
+
+**每条事件的 `data`**
+
+为 JSON 字符串,解析后为对象(或 `{ "data": { ... } }`,前端会解包)。对象内为**增量或全量**字段均可;前端对 SSE 负载做浅合并。
+
+### 看板「手动执行任务」指标(须与实现对齐)
+
+前端见 `Dashboard/RealtimeSection.js`:「等待操作」用 `pickNonNegativeInt` 读取 `waiting` 相关字段;「当日完成」**仅**读取 **`completedToday.manual`**。未下发时显示 `0`。
+
+| 指标 | 含义 | 字段 |
+| --- | --- | --- |
+| 手动 · 当前为 **waiting**(等待操作)的任务数 | 快照:当前处于 `waiting` 状态且 `runnerType === manual` 的任务数量 | **`waitingByRunnerType.manual`**,或 **`runnerTypeStats.manual.waiting`** |
+| 系统 · 同上 | `runnerType === system` | **`waitingByRunnerType.system`**,或 **`runnerTypeStats.system.waiting`** |
+| 手动 · **当日完成**任务数 | 完成时间落在「当日」(由后端按 `timezone` 划界)的手动任务数 | **`completedToday.manual`**(顶层字段,与 `waitingByRunnerType` 并列) |
+| 系统 · 当日完成 | 同上 | **`completedToday.system`** |
+
+(不再使用 `todayCompletedByRunnerType`、`runnerTypeStats.*.completedToday`、`todayCompleted`、`executedToday` 等别名。)
+
+### 其它常用 SSE 字段(节选)
+
+| 字段 | 说明 |
+| --- | --- |
+| `date` | 服务端「今日」日期(YYYY-MM-DD,可与 timezone 对齐校验) |
+| `totalTasks` | 今日任务量等总览 |
+| `byStatus` | 各状态计数(含 `waiting`,与 `pending` 不同) |
+| `byType` / `byRunnerType` | 按类型 / 按执行方式聚合 |
+| `hourlyTrend` / `hourlyTrendByStatus` / `hourlyTrendByType` | 今日按小时序列 |
+| `completedToday` | **当日完成**按 `manual` / `system` 分组(顶层对象,与 `waitingByRunnerType` 并列) |
+| `pendingByRunnerType` | **pending(等待执行)** 按 `manual` / `system` 分组;与看板「手动等待操作数」(`waiting`)无关 |
+| `runnerTypeStats` | 可按 runner 扩展;看板「手动等待操作数」仅读 **`waiting` / `waitingCount`**;**`executed`** 勿用作「当日完成」(当日完成仅 **`completedToday`**) |
+| `todayDuration` | 今日耗时统计(均值等) |
diff --git a/src/components/Task/locale/en-US.js b/src/components/Task/locale/en-US.js
index a485bfd..15fe5e6 100644
--- a/src/components/Task/locale/en-US.js
+++ b/src/components/Task/locale/en-US.js
@@ -76,13 +76,18 @@ const locale = {
ManualExecutionStats: 'Manual Execution Tasks',
ManualExecutionGoMyTaskTitle: 'Click here to open My Tasks',
ManualExecutionTasks: 'Manual Task Count',
- ManualPendingTasks: 'Manual Pending Count',
- ManualExecutedTasks: 'Manual Executed Count',
+ ManualPendingTasks: 'Awaiting',
+ ManualExecutedTasks: 'Done today',
+ ManualPendingMaxWaitLabel: 'Max wait',
+ ManualCompletedTotalDurationLabel: 'Total time',
ManualAvgExecutionTime: 'Manual Avg Execution Time',
ManualAvgWaitingTime: 'Manual Avg Waiting Time',
ManualAvgTotalTime: 'Manual Avg Total Time',
ExecutionTimeStatistics: 'Execution Time Statistics',
ExecutionTimeByTaskType: 'Execution Time by Task Type',
+ DurationByTypeRunnerAll: 'All',
+ DurationByTypeRunnerManual: 'Manual',
+ DurationByTypeRunnerSystem: 'Auto',
TotalTasks: 'Total Tasks',
Range_7d: 'Last 7 Days',
Range_1m: 'Last Month',
@@ -94,6 +99,9 @@ const locale = {
Evening: 'Evening',
PeriodCompare: 'Period Compare',
TodayHourlyTrend: 'Today Hourly Trend',
+ HistoricalHourlyTrendTitle: 'Hourly trend',
+ HourlyTrendModeByType: 'By task type',
+ HourlyTrendModeByStatus: 'By task status',
StatusDistribution: 'Status Distribution',
TaskTypeDistribution: 'Task Type Distribution'
};
diff --git a/src/components/Task/locale/zh-CN.js b/src/components/Task/locale/zh-CN.js
index 3614b0e..c02af7e 100644
--- a/src/components/Task/locale/zh-CN.js
+++ b/src/components/Task/locale/zh-CN.js
@@ -76,13 +76,18 @@ const locale = {
ManualExecutionStats: '手动执行任务',
ManualExecutionGoMyTaskTitle: '点击此处查看我的任务',
ManualExecutionTasks: '手动执行数量',
- ManualPendingTasks: '手动待执行数',
- ManualExecutedTasks: '手动已执行数',
+ ManualPendingTasks: '等待操作',
+ ManualExecutedTasks: '当日完成',
+ ManualPendingMaxWaitLabel: '最长等待',
+ ManualCompletedTotalDurationLabel: '总耗时',
ManualAvgExecutionTime: '手动平均执行时间',
ManualAvgWaitingTime: '手动平均等待时间',
ManualAvgTotalTime: '手动平均总耗时',
ExecutionTimeStatistics: '执行时间统计',
ExecutionTimeByTaskType: '按任务类型执行时间对比',
+ DurationByTypeRunnerAll: '全部',
+ DurationByTypeRunnerManual: '手动',
+ DurationByTypeRunnerSystem: '自动',
TotalTasks: '任务总数',
Range_7d: '近7天',
Range_1m: '近1个月',
@@ -94,6 +99,9 @@ const locale = {
Evening: '晚间',
PeriodCompare: '时段对比',
TodayHourlyTrend: '今日每小时趋势',
+ HistoricalHourlyTrendTitle: '每小时趋势',
+ HourlyTrendModeByType: '按任务类型',
+ HourlyTrendModeByStatus: '按任务状态',
StatusDistribution: '任务状态分布',
TaskTypeDistribution: '任务类型分布'
};
diff --git a/src/mockPreset/index.js b/src/mockPreset/index.js
index 50e8132..6926e56 100644
--- a/src/mockPreset/index.js
+++ b/src/mockPreset/index.js
@@ -49,6 +49,7 @@ const apis = merge({}, getApis(), {
const recentTrendByStatus = [];
const recentTrendByType = [];
const durationTrend = [];
+ const hourlyCompletionTrend = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
@@ -85,6 +86,22 @@ const apis = merge({}, getApis(), {
system: { count: Math.ceil(total * 0.6), avgWaitingTime: Math.floor(Math.random() * 2000) + 500, avgExecutionTime: Math.floor(Math.random() * 6000) + 2000, avgTotalTime: Math.floor(Math.random() * 8000) + 3000 }
}
});
+ ['import', 'export'].forEach(type => {
+ [9, 12, 15, 18].forEach(h => {
+ const ok = Math.floor(Math.random() * 3) + 1;
+ const bad = Math.random() < 0.15 ? 1 : 0;
+ hourlyCompletionTrend.push({
+ date: dateStr,
+ hour: h,
+ type,
+ runnerType: type === 'import' ? 'system' : 'manual',
+ totalCompleted: ok + bad,
+ successCount: ok,
+ failedCount: bad,
+ canceledCount: 0
+ });
+ });
+ });
}
const totalSuccess = recentTrendByStatus.filter(item => item.status === 'success').reduce((sum, item) => sum + item.count, 0);
const totalFailed = recentTrendByStatus.filter(item => item.status === 'failed').reduce((sum, item) => sum + item.count, 0);
@@ -103,7 +120,8 @@ const apis = merge({}, getApis(), {
recentTrend,
recentTrendByStatus,
recentTrendByType,
- durationTrend
+ durationTrend,
+ hourlyCompletionTrend
};
}
},
@@ -119,8 +137,9 @@ const apis = merge({}, getApis(), {
const failed = Math.floor(Math.random() * 3);
const running = Math.floor(Math.random() * 2);
const pending = Math.floor(Math.random() * 3);
+ const waiting = Math.floor(Math.random() * 2);
const canceled = Math.floor(Math.random() * 2);
- const total = success + failed + running + pending + canceled;
+ const total = success + failed + running + pending + waiting + canceled;
const exportCount = Math.floor(total * 0.6);
const importCount = total - exportCount;
hourlyTrend.push({ hour: h, count: total });
@@ -128,6 +147,7 @@ const apis = merge({}, getApis(), {
if (failed > 0) hourlyTrendByStatus.push({ hour: h, status: 'failed', count: failed });
if (running > 0) hourlyTrendByStatus.push({ hour: h, status: 'running', count: running });
if (pending > 0) hourlyTrendByStatus.push({ hour: h, status: 'pending', count: pending });
+ if (waiting > 0) hourlyTrendByStatus.push({ hour: h, status: 'waiting', count: waiting });
if (canceled > 0) hourlyTrendByStatus.push({ hour: h, status: 'canceled', count: canceled });
if (exportCount > 0) hourlyTrendByType.push({ hour: h, type: 'export', count: exportCount });
if (importCount > 0) hourlyTrendByType.push({ hour: h, type: 'import', count: importCount });
@@ -137,16 +157,45 @@ const apis = merge({}, getApis(), {
const totalFailed = hourlyTrendByStatus.filter(item => item.status === 'failed').reduce((sum, item) => sum + item.count, 0);
const totalRunning = hourlyTrendByStatus.filter(item => item.status === 'running').reduce((sum, item) => sum + item.count, 0);
const totalPending = hourlyTrendByStatus.filter(item => item.status === 'pending').reduce((sum, item) => sum + item.count, 0);
+ const totalWaiting = hourlyTrendByStatus.filter(item => item.status === 'waiting').reduce((sum, item) => sum + item.count, 0);
const totalCanceled = hourlyTrendByStatus.filter(item => item.status === 'canceled').reduce((sum, item) => sum + item.count, 0);
+ const manualWaiting = Math.max(0, Math.floor(totalWaiting * 0.5));
+ const systemWaiting = Math.max(0, totalWaiting - manualWaiting);
+ const manualCompletedToday = Math.max(0, Math.floor(totalSuccess * 0.35));
+ const systemCompletedToday = Math.max(0, totalSuccess - manualCompletedToday);
+ // 看板 KPI:waiting 用 waitingByRunnerType;当日完成仅用顶层 completedToday(见 Task doc/api.md)
return {
date: now.toISOString().split('T')[0],
totalTasks,
- byStatus: { success: totalSuccess, failed: totalFailed, running: totalRunning, pending: totalPending, canceled: totalCanceled },
+ byStatus: {
+ success: totalSuccess,
+ failed: totalFailed,
+ running: totalRunning,
+ pending: totalPending,
+ waiting: totalWaiting,
+ canceled: totalCanceled
+ },
byType: { export: Math.floor(totalTasks * 0.6), import: Math.ceil(totalTasks * 0.4) },
byRunnerType: { manual: Math.floor(totalTasks * 0.4), system: Math.ceil(totalTasks * 0.6) },
hourlyTrend,
hourlyTrendByStatus,
hourlyTrendByType,
+ waitingByRunnerType: {
+ manual: manualWaiting,
+ system: systemWaiting
+ },
+ completedToday: {
+ manual: manualCompletedToday,
+ system: systemCompletedToday
+ },
+ waitingQueueMaxWaitMsByRunnerType: {
+ manual: manualWaiting > 0 ? 180000 + Math.floor(Math.random() * 240000) : 0,
+ system: systemWaiting > 0 ? 120000 + Math.floor(Math.random() * 180000) : 0
+ },
+ completedTodayTotalDurationMsByRunnerType: {
+ manual: manualCompletedToday * (90000 + Math.floor(Math.random() * 120000)),
+ system: systemCompletedToday * 60000
+ },
pendingByRunnerType: {
manual: Math.max(0, Math.floor(totalPending * 0.45)),
system: Math.max(0, totalPending - Math.floor(totalPending * 0.45))
@@ -160,12 +209,14 @@ const apis = merge({}, getApis(), {
manual: {
total: manualTotal,
pending: manualPending,
- executed: Math.max(0, manualTotal - manualPending)
+ executed: Math.max(0, manualTotal - manualPending),
+ waiting: manualWaiting
},
system: {
total: systemTotal,
pending: systemPending,
- executed: Math.max(0, systemTotal - systemPending)
+ executed: Math.max(0, systemTotal - systemPending),
+ waiting: systemWaiting
}
};
})(),
@@ -176,7 +227,31 @@ const apis = merge({}, getApis(), {
canceledCount: totalCanceled,
avgWaitingTime: Math.floor(Math.random() * 3000) + 500,
avgExecutionTime: Math.floor(Math.random() * 8000) + 2000,
- avgTotalTime: Math.floor(Math.random() * 10000) + 3000
+ avgTotalTime: Math.floor(Math.random() * 10000) + 3000,
+ byType: {
+ export: {
+ count: Math.max(1, Math.floor(totalSuccess * 0.35)),
+ avgWaitingTime: 800,
+ avgExecutionTime: 2200,
+ avgTotalTime: 3200
+ },
+ import: {
+ count: Math.max(1, Math.floor(totalSuccess * 0.25)),
+ avgWaitingTime: 1200,
+ avgExecutionTime: 3100,
+ avgTotalTime: 4500
+ }
+ },
+ byTypeByRunnerType: {
+ manual: {
+ export: { count: 2, avgWaitingTime: 900, avgExecutionTime: 2000, avgTotalTime: 3000 },
+ import: { count: 1, avgWaitingTime: 1100, avgExecutionTime: 2800, avgTotalTime: 4000 }
+ },
+ system: {
+ export: { count: 4, avgWaitingTime: 700, avgExecutionTime: 2400, avgTotalTime: 3400 },
+ import: { count: 3, avgWaitingTime: 1300, avgExecutionTime: 3300, avgTotalTime: 4800 }
+ }
+ }
}
};
}